test(timetable): Phase C — 57 new tests covering session 2 surface

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:07:29 +02:00
parent 5c1faf2061
commit 996dedc11d
15 changed files with 1581 additions and 12 deletions

View File

@@ -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');

View File

@@ -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 [

View File

@@ -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(

View File

@@ -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');
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Services\Artist\ArtistEngagementService;
use App\Services\Artist\LaneCascadeService;
use App\Services\Artist\StageDayService;
use App\Services\Artist\StageService;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class ActivityLogShapeTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Stage $stage;
private ArtistEngagement $engagement;
private Performance $perf;
protected function setUp(): void
{
parent::setUp();
$this->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(),
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class ArtistControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Organisation $otherOrg;
private User $orgAdmin;
private User $programManager;
private User $outsider;
protected function setUp(): void
{
parent::setUp();
$this->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]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class ArtistEngagementControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->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]);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Exceptions\Artist\InvalidStatusTransitionException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Services\Artist\ArtistEngagementService;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ArtistEngagementStateMachineTest extends TestCase
{
use RefreshDatabase;
private ArtistEngagementService $service;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
final class ArtistPolicyTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Organisation $otherOrg;
private User $orgAdmin;
private User $programManager;
private User $crossTenantAdmin;
private User $superAdmin;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\BumaHandledBy;
use App\Http\Resources\Api\V1\Artist\ArtistEngagementResource;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
/**
* RFC v0.2 D26 Buma + VAT computation.
*/
final class BumaVatCalculationTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class DemoteExpiredOptionsTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Http\Middleware\IdempotencyKey60sRedis;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
final class IdempotencyKey60sRedisTest extends TestCase
{
public function test_missing_header_returns_400(): void
{
$middleware = new IdempotencyKey60sRedis;
$request = Request::create('/x', 'POST');
$response = $middleware->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');
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Exceptions\Artist\VersionMismatchException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Services\Artist\LaneCascadeService;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class LaneCascadeServiceTest extends TestCase
{
use RefreshDatabase;
private LaneCascadeService $service;
private Organisation $org;
private Event $event;
private Stage $stage;
private ArtistEngagement $engagement;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class StageControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Models\User;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class TimetableMoveControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
private Stage $stage;
private Performance $perf;
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}