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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
177
api/tests/Feature/Artist/ActivityLogShapeTest.php
Normal file
177
api/tests/Feature/Artist/ActivityLogShapeTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
api/tests/Feature/Artist/ArtistControllerTest.php
Normal file
145
api/tests/Feature/Artist/ArtistControllerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
115
api/tests/Feature/Artist/ArtistEngagementControllerTest.php
Normal file
115
api/tests/Feature/Artist/ArtistEngagementControllerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
153
api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php
Normal file
153
api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
105
api/tests/Feature/Artist/ArtistPolicyTest.php
Normal file
105
api/tests/Feature/Artist/ArtistPolicyTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
155
api/tests/Feature/Artist/BumaVatCalculationTest.php
Normal file
155
api/tests/Feature/Artist/BumaVatCalculationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
116
api/tests/Feature/Artist/DemoteExpiredOptionsTest.php
Normal file
116
api/tests/Feature/Artist/DemoteExpiredOptionsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
98
api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php
Normal file
98
api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
187
api/tests/Feature/Artist/LaneCascadeServiceTest.php
Normal file
187
api/tests/Feature/Artist/LaneCascadeServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
api/tests/Feature/Artist/StageControllerTest.php
Normal file
186
api/tests/Feature/Artist/StageControllerTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
131
api/tests/Feature/Artist/TimetableMoveControllerTest.php
Normal file
131
api/tests/Feature/Artist/TimetableMoveControllerTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user