From 996dedc11df71f70b8ba5fdb5483b3387e3c559b Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:07:29 +0200 Subject: [PATCH] =?UTF-8?q?test(timetable):=20Phase=20C=20=E2=80=94=2057?= =?UTF-8?q?=20new=20tests=20covering=20session=202=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine test files under tests/Feature/Artist/ exercising: ArtistEngagementStateMachineTest 8 tests — terminal blocks, conditional gates (Option/Contracted), full happy path, cancel cascade LaneCascadeServiceTest 5 tests — simple move, cascade-bump, version mismatch, park, unpark BumaVatCalculationTest 6 tests — D26 formula coverage: Organisation/BookingAgency/NotApplicable, VAT off, breakdown sum, zero fee DemoteExpiredOptionsTest 4 tests — expired demote, future untouched, non-Option untouched, run twice → single option_expired entry IdempotencyKey60sRedisTest 4 tests — missing header 400, first cache, replay header, failed not cached ArtistControllerTest 8 tests — index/create/destroy + cross- tenant + duplicate detection + restore StageControllerTest 7 tests — create + uniqueness, destroy cascade-park, reorder permutation, replaceDays orphan 409 + force_orphan ArtistEngagementControllerTest 5 tests — index/create/update/destroy + 422 on invalid status transition TimetableMoveControllerTest 3 tests — happy path with idempotency header, missing header → 400, version mismatch → 409 ArtistPolicyTest 6 tests — role checks, cross-tenant denial, super_admin bypass, D27 active- engagement gate ActivityLogShapeTest 4 tests — performance.moved cascade props, status_changed vs cancelled, stage.day_added subject + props, stage.reordered on Event subject Bug fixes surfaced by Phase C: Schema reality: events table uses `start_date`/`end_date` (date), not `start_at`/`end_at`. Updated WithinEventBounds rule and the two stage_day resolvers (LaneCascadeService + MoveTimetablePerformanceRequest) to query the actual columns. ArtistResource.engagements_summary upcoming filter likewise. performances table has no organisation_id column (FK-chain via engagement_id). Removed the org-id filter from the Rule::exists in MoveTimetablePerformanceRequest; cross-tenant is caught by the policy in TimetableMoveController. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MoveTimetablePerformanceRequest.php | 13 +- .../Api/V1/Artist/ArtistResource.php | 2 +- api/app/Rules/Artist/WithinEventBounds.php | 4 +- .../Services/Artist/LaneCascadeService.php | 6 +- .../Feature/Artist/ActivityLogShapeTest.php | 177 +++++++++++++++++ .../Feature/Artist/ArtistControllerTest.php | 145 ++++++++++++++ .../Artist/ArtistEngagementControllerTest.php | 115 +++++++++++ .../ArtistEngagementStateMachineTest.php | 153 ++++++++++++++ api/tests/Feature/Artist/ArtistPolicyTest.php | 105 ++++++++++ .../Feature/Artist/BumaVatCalculationTest.php | 155 +++++++++++++++ .../Artist/DemoteExpiredOptionsTest.php | 116 +++++++++++ .../Artist/IdempotencyKey60sRedisTest.php | 98 +++++++++ .../Feature/Artist/LaneCascadeServiceTest.php | 187 ++++++++++++++++++ .../Feature/Artist/StageControllerTest.php | 186 +++++++++++++++++ .../Artist/TimetableMoveControllerTest.php | 131 ++++++++++++ 15 files changed, 1581 insertions(+), 12 deletions(-) create mode 100644 api/tests/Feature/Artist/ActivityLogShapeTest.php create mode 100644 api/tests/Feature/Artist/ArtistControllerTest.php create mode 100644 api/tests/Feature/Artist/ArtistEngagementControllerTest.php create mode 100644 api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php create mode 100644 api/tests/Feature/Artist/ArtistPolicyTest.php create mode 100644 api/tests/Feature/Artist/BumaVatCalculationTest.php create mode 100644 api/tests/Feature/Artist/DemoteExpiredOptionsTest.php create mode 100644 api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php create mode 100644 api/tests/Feature/Artist/LaneCascadeServiceTest.php create mode 100644 api/tests/Feature/Artist/StageControllerTest.php create mode 100644 api/tests/Feature/Artist/TimetableMoveControllerTest.php diff --git a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php index 192f2078..083bd76b 100644 --- a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php +++ b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1\Artist; -use App\Models\Event; use App\Models\Stage; use App\Models\StageDay; use App\Rules\Artist\StageActiveOnEvent; @@ -30,13 +29,15 @@ final class MoveTimetablePerformanceRequest extends FormRequest public function rules(): array { $event = $this->route('event'); - $organisationId = $event instanceof Event ? $event->organisation_id : null; $resolvedEventId = $this->resolveTargetEventId(); return [ + // performances has no organisation_id column (FK-chain via + // engagement_id); cross-tenant is caught by the policy in + // TimetableMoveController via Gate::authorize('move', ...). 'performance_id' => [ 'required', 'string', 'max:30', - Rule::exists('performances', 'id')->where('organisation_id', $organisationId), + Rule::exists('performances', 'id'), ], 'target_stage_id' => [ 'nullable', 'string', 'max:30', @@ -96,9 +97,9 @@ final class MoveTimetablePerformanceRequest extends FormRequest $match = StageDay::query() ->where('stage_id', $stage->id) ->join('events', 'events.id', '=', 'stage_days.event_id') - ->where('events.start_at', '<=', $start) - ->where('events.end_at', '>=', $start) - ->orderBy('events.start_at', 'desc') + ->where('events.start_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', 'desc') ->limit(1) ->value('stage_days.event_id'); diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php index 3fb3dbb6..0df72db5 100644 --- a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php @@ -33,7 +33,7 @@ final class ArtistResource extends JsonResource ArtistEngagementStatus::Rejected->value, ArtistEngagementStatus::Declined->value, ]) - ->whereHas('event', fn ($q) => $q->where('end_at', '>=', now())) + ->whereHas('event', fn ($q) => $q->where('end_date', '>=', now()->toDateString())) ->count(); return [ diff --git a/api/app/Rules/Artist/WithinEventBounds.php b/api/app/Rules/Artist/WithinEventBounds.php index 255d8118..6935cd7a 100644 --- a/api/app/Rules/Artist/WithinEventBounds.php +++ b/api/app/Rules/Artist/WithinEventBounds.php @@ -37,8 +37,8 @@ final class WithinEventBounds implements ValidationRule } $candidate = CarbonImmutable::parse((string) $value); - $start = CarbonImmutable::instance($event->start_at); - $end = CarbonImmutable::instance($event->end_at); + $start = CarbonImmutable::instance($event->start_date)->startOfDay(); + $end = CarbonImmutable::instance($event->end_date)->endOfDay(); if ($candidate->lt($start) || $candidate->gt($end)) { $fail(sprintf( diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php index d759325f..ead555e7 100644 --- a/api/app/Services/Artist/LaneCascadeService.php +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -148,9 +148,9 @@ final class LaneCascadeService { return $stage->stageDays() ->join('events', 'events.id', '=', 'stage_days.event_id') - ->where('events.start_at', '<=', $start) - ->where('events.end_at', '>=', $start) - ->orderBy('events.start_at', 'desc') + ->where('events.start_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', 'desc') ->limit(1) ->value('stage_days.event_id'); } diff --git a/api/tests/Feature/Artist/ActivityLogShapeTest.php b/api/tests/Feature/Artist/ActivityLogShapeTest.php new file mode 100644 index 00000000..164c1826 --- /dev/null +++ b/api/tests/Feature/Artist/ActivityLogShapeTest.php @@ -0,0 +1,177 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $this->engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'fee_amount' => 1500, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + } + + public function test_performance_moved_carries_cascade_props(): void + { + $other = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => CarbonImmutable::now()->addDays(2)->setTime(20, 30), + 'end_at' => CarbonImmutable::now()->addDays(2)->setTime(21, 30), + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->app->make(LaneCascadeService::class)->move( + performance: $this->perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $entry = Activity::query() + ->where('event', 'moved') + ->where('subject_id', $this->perf->id) + ->latest('id') + ->first(); + + $this->assertNotNull($entry); + $props = $entry->properties->toArray(); + $this->assertArrayHasKey('cascade_count', $props); + $this->assertArrayHasKey('cascaded_ids', $props); + $this->assertSame(1, $props['cascade_count']); + $this->assertContains((string) $other->id, $props['cascaded_ids']); + } + + public function test_status_changed_distinct_from_cancelled(): void + { + $service = $this->app->make(ArtistEngagementService::class); + $service->transitionStatus($this->engagement, ArtistEngagementStatus::Requested); + + $this->assertTrue( + Activity::query() + ->where('event', 'status_changed') + ->where('subject_id', $this->engagement->id) + ->exists(), + ); + $this->assertFalse( + Activity::query() + ->where('event', 'cancelled') + ->where('subject_id', $this->engagement->id) + ->exists(), + ); + + $eng2 = ArtistEngagement::factory()->create([ + 'artist_id' => Artist::factory()->create(['organisation_id' => $this->org->id])->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'fee_amount' => 1000, + ]); + $service->cancel($eng2); + + $this->assertTrue( + Activity::query() + ->where('event', 'cancelled') + ->where('subject_id', $eng2->id) + ->exists(), + ); + } + + public function test_stage_day_added_emitted(): void + { + $sub = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + $this->app->make(StageDayService::class)->replaceDays( + $this->stage, + [$this->event->id, $sub->id], + ); + + $this->assertTrue( + Activity::query() + ->where('event', 'day_added') + ->where('subject_id', $this->stage->id) + ->whereJsonContains('properties->event_id', $sub->id) + ->exists(), + ); + } + + public function test_stage_reordered_emitted_on_event_subject(): void + { + $other = Stage::factory()->create(['event_id' => $this->event->id]); + + $this->app->make(StageService::class)->reorder($this->event, [$other->id, $this->stage->id]); + + $this->assertTrue( + Activity::query() + ->where('event', 'reordered') + ->where('subject_id', $this->event->id) + ->exists(), + ); + } +} diff --git a/api/tests/Feature/Artist/ArtistControllerTest.php b/api/tests/Feature/Artist/ArtistControllerTest.php new file mode 100644 index 00000000..f25db26c --- /dev/null +++ b/api/tests/Feature/Artist/ArtistControllerTest.php @@ -0,0 +1,145 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->programManager = User::factory()->create(); + $this->org->users()->attach($this->programManager, ['role' => 'program_manager']); + + $this->outsider = User::factory()->create(); + $this->otherOrg->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + public function test_index_lists_artists_for_member(): void + { + Artist::factory()->count(3)->create(['organisation_id' => $this->org->id]); + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/artists"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_unauthenticated_returns_401(): void + { + $this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertUnauthorized(); + } + + public function test_outsider_cannot_view_other_org_artists(): void + { + Artist::factory()->create(['organisation_id' => $this->org->id]); + Sanctum::actingAs($this->outsider); + + $this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertForbidden(); + } + + public function test_program_manager_can_create(): void + { + Sanctum::actingAs($this->programManager); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [ + 'name' => 'Headhunterz', + ]); + + $response->assertCreated(); + $this->assertSame('Headhunterz', $response->json('data.name')); + } + + public function test_duplicate_name_returns_409_with_existing_id(): void + { + $existing = Artist::factory()->create([ + 'organisation_id' => $this->org->id, + 'name' => 'Devin Wild', + ]); + + Sanctum::actingAs($this->programManager); + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [ + 'name' => 'devin wild', + ]); + + $response->assertStatus(409); + $this->assertSame((string) $existing->id, (string) $response->json('errors.duplicate_artist_id')); + } + + public function test_destroy_blocked_with_active_engagement(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]); + } + + public function test_destroy_with_only_terminal_engagements_succeeds(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}") + ->assertNoContent(); + } + + public function test_restore_brings_back_soft_deleted_artist(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $artist->delete(); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}/restore"); + + $response->assertOk(); + $this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]); + } +} diff --git a/api/tests/Feature/Artist/ArtistEngagementControllerTest.php b/api/tests/Feature/Artist/ArtistEngagementControllerTest.php new file mode 100644 index 00000000..9d5cd063 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistEngagementControllerTest.php @@ -0,0 +1,115 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + private function url(string $tail = ''): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/engagements{$tail}"; + } + + public function test_index_returns_engagements(): void + { + $a = Artist::factory()->create(['organisation_id' => $this->org->id]); + $b = Artist::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create(['artist_id' => $a->id, 'event_id' => $this->event->id]); + ArtistEngagement::factory()->create(['artist_id' => $b->id, 'event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + $this->getJson($this->url())->assertOk()->assertJsonCount(2, 'data'); + } + + public function test_create_engagement(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'artist_id' => $this->artist->id, + 'booking_status' => ArtistEngagementStatus::Draft->value, + ]); + + $response->assertCreated(); + } + + public function test_create_with_invalid_status_transition_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'artist_id' => $this->artist->id, + 'booking_status' => ArtistEngagementStatus::Option->value, + // Missing option_expires_at — service should refuse + ]); + + $response->assertStatus(422); + } + + public function test_update_status_transition(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + ]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->patchJson($this->url("/{$eng->id}"), [ + 'booking_status' => ArtistEngagementStatus::Requested->value, + ]); + + $response->assertOk(); + $this->assertSame( + ArtistEngagementStatus::Requested, + $eng->refresh()->booking_status, + ); + } + + public function test_destroy_soft_deletes(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson($this->url("/{$eng->id}"))->assertNoContent(); + + $this->assertSoftDeleted('artist_engagements', ['id' => $eng->id]); + } +} diff --git a/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php b/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php new file mode 100644 index 00000000..5ccb63a0 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php @@ -0,0 +1,153 @@ +seed(RoleSeeder::class); + + $this->service = $this->app->make(ArtistEngagementService::class); + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_rejected_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Rejected, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted); + } + + public function test_declined_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Declined, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Draft); + } + + public function test_cancelled_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Confirmed); + } + + public function test_option_requires_future_expiry(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'option_expires_at' => null, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Option); + } + + public function test_option_with_past_expiry_blocked(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'option_expires_at' => now()->subHour(), + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Option); + } + + public function test_contracted_requires_fee(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'fee_amount' => null, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted); + } + + public function test_happy_path_sequence_permitted(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'fee_amount' => 1500.00, + 'option_expires_at' => now()->addDays(14), + ]); + + foreach ([ + ArtistEngagementStatus::Requested, + ArtistEngagementStatus::Option, + ArtistEngagementStatus::Offered, + ArtistEngagementStatus::Confirmed, + ArtistEngagementStatus::Contracted, + ] as $next) { + $this->service->transitionStatus($eng, $next); + $this->assertSame($next, $eng->refresh()->booking_status); + } + } + + public function test_cancel_transitions_and_soft_deletes(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + $this->service->cancel($eng); + + $reloaded = ArtistEngagement::withoutGlobalScopes()->withTrashed()->find($eng->id); + $this->assertNotNull($reloaded); + $this->assertSame(ArtistEngagementStatus::Cancelled, $reloaded->booking_status); + $this->assertNotNull($reloaded->deleted_at); + } +} diff --git a/api/tests/Feature/Artist/ArtistPolicyTest.php b/api/tests/Feature/Artist/ArtistPolicyTest.php new file mode 100644 index 00000000..111850db --- /dev/null +++ b/api/tests/Feature/Artist/ArtistPolicyTest.php @@ -0,0 +1,105 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->programManager = User::factory()->create(); + $this->org->users()->attach($this->programManager, ['role' => 'program_manager']); + + $this->crossTenantAdmin = User::factory()->create(); + $this->otherOrg->users()->attach($this->crossTenantAdmin, ['role' => 'org_admin']); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_org_admin_can_create(): void + { + $this->assertTrue(Gate::forUser($this->orgAdmin)->allows('create', [Artist::class, $this->org])); + } + + public function test_program_manager_can_update(): void + { + $this->assertTrue(Gate::forUser($this->programManager)->allows('update', $this->artist)); + } + + public function test_cross_tenant_admin_denied(): void + { + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('view', $this->artist)); + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('update', $this->artist)); + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('delete', $this->artist)); + } + + public function test_super_admin_bypass(): void + { + $this->assertTrue(Gate::forUser($this->superAdmin)->allows('view', $this->artist)); + $this->assertTrue(Gate::forUser($this->superAdmin)->allows('update', $this->artist)); + } + + public function test_delete_blocked_with_active_engagement(): void + { + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + $this->assertFalse(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist)); + } + + public function test_delete_allowed_with_only_terminal_engagements(): void + { + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + $this->assertTrue(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist)); + } +} diff --git a/api/tests/Feature/Artist/BumaVatCalculationTest.php b/api/tests/Feature/Artist/BumaVatCalculationTest.php new file mode 100644 index 00000000..a0fb2c07 --- /dev/null +++ b/api/tests/Feature/Artist/BumaVatCalculationTest.php @@ -0,0 +1,155 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + private function compute(array $attrs): array + { + $eng = ArtistEngagement::factory()->create(array_merge([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + ], $attrs)); + + $req = Request::create('/'); + $payload = (new ArtistEngagementResource($eng))->toArray($req); + + return $payload['computed']; + } + + public function test_organisation_handles_buma_includes_buma_in_vat_grondslag(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(70.0, $c['buma_amount']); + $this->assertSame(1070.0, $c['vat_grondslag']); + $this->assertSame(224.7, $c['vat_amount']); + $this->assertSame(1294.7, $c['total_cost']); + } + + public function test_booking_agency_handles_buma_excludes_from_vat_grondslag(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::BookingAgency, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(1000.0, $c['vat_grondslag']); + $this->assertSame(210.0, $c['vat_amount']); + $this->assertSame(1210.0, $c['total_cost']); + } + + public function test_not_applicable_buma_yields_zero_buma(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => false, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::NotApplicable, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(1000.0, $c['vat_grondslag']); + } + + public function test_vat_disabled_yields_zero_vat(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => false, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(70.0, $c['buma_amount']); + $this->assertSame(0.0, $c['vat_amount']); + } + + public function test_breakdown_summed_into_total_cost(): void + { + $c = $this->compute([ + 'fee_amount' => 500.00, + 'buma_applicable' => false, + 'buma_handled_by' => BumaHandledBy::NotApplicable, + 'vat_applicable' => false, + 'deal_breakdown' => [ + ['label' => 'Hospitality', 'amount' => 50.00], + ['label' => 'Hotel', 'amount' => 120.00], + ], + ]); + + $this->assertSame(170.0, $c['breakdown_total']); + $this->assertSame(670.0, $c['total_cost']); + } + + public function test_zero_fee_yields_zero_components(): void + { + $c = $this->compute([ + 'fee_amount' => 0, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(0.0, $c['vat_amount']); + $this->assertSame(0.0, $c['total_cost']); + } +} diff --git a/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php b/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php new file mode 100644 index 00000000..0d961501 --- /dev/null +++ b/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php @@ -0,0 +1,116 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_expired_option_demoted_to_draft(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->subMinute(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Draft, + $eng->refresh()->booking_status, + ); + + $this->assertTrue( + Activity::query() + ->where('subject_type', $eng->getMorphClass()) + ->where('subject_id', $eng->id) + ->where('event', 'option_expired') + ->exists(), + ); + } + + public function test_future_option_untouched(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->addHour(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Option, + $eng->refresh()->booking_status, + ); + } + + public function test_non_option_status_untouched(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'option_expires_at' => now()->subDay(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Confirmed, + $eng->refresh()->booking_status, + ); + } + + public function test_running_twice_writes_only_one_option_expired_entry(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->subMinute(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $count = Activity::query() + ->where('subject_type', $eng->getMorphClass()) + ->where('subject_id', $eng->id) + ->where('event', 'option_expired') + ->count(); + + $this->assertSame(1, $count); + } +} diff --git a/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php b/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php new file mode 100644 index 00000000..d263aecf --- /dev/null +++ b/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php @@ -0,0 +1,98 @@ +handle($request, fn () => response('ok')); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('idempotency_key_required', (string) $response->getContent()); + } + + public function test_first_request_caches_and_passes_through(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + $request = Request::create('/x', 'POST'); + $request->headers->set('Idempotency-Key', 'abc-123'); + + $count = 0; + $response = $middleware->handle($request, function () use (&$count): Response { + $count++; + + return response()->json(['ok' => true]); + }); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(1, $count); + } + + public function test_replayed_request_returns_cached_body_with_replayed_header(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + + $request1 = Request::create('/x', 'POST'); + $request1->headers->set('Idempotency-Key', 'replay-key'); + + $count = 0; + $middleware->handle($request1, function () use (&$count) { + $count++; + + return response()->json(['result' => 'one']); + }); + + $request2 = Request::create('/x', 'POST'); + $request2->headers->set('Idempotency-Key', 'replay-key'); + + $response2 = $middleware->handle($request2, function () use (&$count) { + $count++; + + return response()->json(['result' => 'two']); + }); + + $this->assertSame(1, $count, 'inner handler should not run on replay'); + $this->assertSame('true', $response2->headers->get('Idempotency-Replayed')); + $this->assertStringContainsString('one', (string) $response2->getContent()); + } + + public function test_failed_response_not_cached(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + + $request1 = Request::create('/x', 'POST'); + $request1->headers->set('Idempotency-Key', 'fail-key'); + + $middleware->handle($request1, fn () => response()->json(['x' => 1], 422)); + + $request2 = Request::create('/x', 'POST'); + $request2->headers->set('Idempotency-Key', 'fail-key'); + + $count = 0; + $middleware->handle($request2, function () use (&$count) { + $count++; + + return response()->json(['x' => 2]); + }); + + $this->assertSame(1, $count, 'failed responses should not be cached for replay'); + } +} diff --git a/api/tests/Feature/Artist/LaneCascadeServiceTest.php b/api/tests/Feature/Artist/LaneCascadeServiceTest.php new file mode 100644 index 00000000..3d463f2c --- /dev/null +++ b/api/tests/Feature/Artist/LaneCascadeServiceTest.php @@ -0,0 +1,187 @@ +seed(RoleSeeder::class); + + $this->service = $this->app->make(LaneCascadeService::class); + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $this->engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + } + + public function test_simple_move_no_overlap_succeeds(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $result = $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertSame([], $result->cascaded); + $this->assertGreaterThan(0, $result->moved->version); + } + + public function test_overlap_cascades_existing_to_higher_lane(): void + { + $start = CarbonImmutable::now()->addDays(3)->setTime(22, 0); + + $existing = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $other = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => null, // parked + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $result = $this->service->move( + performance: $other, + targetStage: $this->stage, + start: $start->addMinutes(15), + end: $start->addMinutes(75), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertCount(1, $result->cascaded); + $this->assertSame((string) $existing->id, (string) $result->cascaded[0]->id); + $this->assertSame(1, (int) $result->cascaded[0]->lane); + } + + public function test_version_mismatch_throws(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'version' => 5, + ]); + + $this->expectException(VersionMismatchException::class); + + $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: CarbonImmutable::parse((string) $perf->start_at), + end: CarbonImmutable::parse((string) $perf->end_at), + targetLane: 0, + clientVersion: 4, + ); + } + + public function test_park_clears_stage_id(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 2, + 'version' => 0, + ]); + + $result = $this->service->move( + performance: $perf, + targetStage: null, + start: null, + end: null, + targetLane: null, + clientVersion: 0, + ); + + $this->assertNull($result->moved->stage_id); + $this->assertSame([], $result->cascaded); + $this->assertSame(2, (int) $result->moved->lane); + } + + public function test_unpark_to_stage_succeeds(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => null, + 'lane' => 0, + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(4)->setTime(21, 0); + $result = $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertSame((string) $this->stage->id, (string) $result->moved->stage_id); + } +} diff --git a/api/tests/Feature/Artist/StageControllerTest.php b/api/tests/Feature/Artist/StageControllerTest.php new file mode 100644 index 00000000..a9144703 --- /dev/null +++ b/api/tests/Feature/Artist/StageControllerTest.php @@ -0,0 +1,186 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + } + + private function url(string $tail = ''): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages{$tail}"; + } + + public function test_create_stage(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'name' => 'Mainstage', + 'color' => '#ff0000', + 'capacity' => 5000, + ]); + + $response->assertCreated(); + $this->assertSame('Mainstage', $response->json('data.name')); + } + + public function test_create_unique_name_per_event(): void + { + Stage::factory()->create(['event_id' => $this->event->id, 'name' => 'Hardstyle']); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url(), [ + 'name' => 'Hardstyle', + 'color' => '#000000', + ]); + + $response->assertStatus(422); + } + + public function test_destroy_cascade_parks_performances(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + $perf = Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson($this->url("/{$stage->id}"))->assertOk(); + + $perf->refresh(); + $this->assertNull($perf->stage_id); + } + + public function test_reorder_updates_sort_order(): void + { + $a = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 0]); + $b = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 1]); + $c = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 2]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url('/order'), [ + 'stage_ids' => [$c->id, $a->id, $b->id], + ]); + + $response->assertOk(); + $this->assertSame(0, (int) $c->fresh()->sort_order); + $this->assertSame(1, (int) $a->fresh()->sort_order); + $this->assertSame(2, (int) $b->fresh()->sort_order); + } + + public function test_reorder_rejects_partial_permutation(): void + { + $a = Stage::factory()->create(['event_id' => $this->event->id]); + Stage::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url('/order'), ['stage_ids' => [$a->id]]); + + $response->assertStatus(422); + } + + public function test_replace_days_orphans_performances_returns_409(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + // Build a sub-event (different event_id) to replace days with + $other = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + $response = $this->putJson("/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days", [ + 'event_ids' => [$other->id], + ]); + + $response->assertStatus(409); + $this->assertSame('orphaned_performances', $response->json('errors.conflict')); + } + + public function test_replace_days_with_force_orphan_succeeds(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + $other = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days?force_orphan=true", + ['event_ids' => [$other->id]], + ); + + $response->assertOk(); + } +} diff --git a/api/tests/Feature/Artist/TimetableMoveControllerTest.php b/api/tests/Feature/Artist/TimetableMoveControllerTest.php new file mode 100644 index 00000000..eae33184 --- /dev/null +++ b/api/tests/Feature/Artist/TimetableMoveControllerTest.php @@ -0,0 +1,131 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->perf = Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + } + + private function url(): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/timetable/move"; + } + + public function test_move_succeeds_with_idempotency_key(): void + { + Sanctum::actingAs($this->orgAdmin); + + $newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour(); + $response = $this->postJson( + $this->url(), + [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => $newStart->format('Y-m-d H:i:s'), + 'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'), + 'target_lane' => 0, + 'version' => 0, + ], + ['Idempotency-Key' => 'test-1'], + ); + + $response->assertOk(); + } + + public function test_move_without_idempotency_key_returns_400(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => '2026-07-10 22:00:00', + 'target_end_at' => '2026-07-10 23:00:00', + 'target_lane' => 0, + 'version' => 0, + ]); + + $response->assertStatus(400); + } + + public function test_version_mismatch_returns_409(): void + { + Sanctum::actingAs($this->orgAdmin); + + $newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour(); + $response = $this->postJson( + $this->url(), + [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => $newStart->format('Y-m-d H:i:s'), + 'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'), + 'target_lane' => 0, + 'version' => 99, + ], + ['Idempotency-Key' => 'test-2'], + ); + + $response->assertStatus(409); + $this->assertSame('version_mismatch', $response->json('errors.conflict')); + } +}