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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user