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>
109 lines
3.7 KiB
PHP
109 lines
3.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Requests\Api\V1\Artist;
|
|
|
|
use App\Models\Stage;
|
|
use App\Models\StageDay;
|
|
use App\Rules\Artist\StageActiveOnEvent;
|
|
use App\Rules\Artist\WithinEventBounds;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Contracts\Validation\Validator;
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/**
|
|
* RFC v0.2 §10.4 — D18 transactional move endpoint.
|
|
*/
|
|
final class MoveTimetablePerformanceRequest extends FormRequest
|
|
{
|
|
public function authorize(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function rules(): array
|
|
{
|
|
$event = $this->route('event');
|
|
$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'),
|
|
],
|
|
'target_stage_id' => [
|
|
'nullable', 'string', 'max:30',
|
|
Rule::exists('stages', 'id'),
|
|
new StageActiveOnEvent($resolvedEventId),
|
|
],
|
|
'target_start_at' => [
|
|
'nullable', 'date_format:Y-m-d H:i:s',
|
|
'required_unless:target_stage_id,null',
|
|
new WithinEventBounds($resolvedEventId),
|
|
],
|
|
'target_end_at' => [
|
|
'nullable', 'date_format:Y-m-d H:i:s',
|
|
'required_unless:target_stage_id,null',
|
|
'after:target_start_at',
|
|
new WithinEventBounds($resolvedEventId),
|
|
],
|
|
'target_lane' => ['nullable', 'integer', 'min:0', 'max:9'],
|
|
'version' => ['required', 'integer', 'min:0'],
|
|
];
|
|
}
|
|
|
|
public function withValidator(Validator $validator): void
|
|
{
|
|
$validator->after(function (Validator $validator): void {
|
|
// When target_stage_id is non-null, target_lane must be set
|
|
// (the move algorithm requires a definite lane).
|
|
if ($this->input('target_stage_id') !== null && $this->input('target_lane') === null) {
|
|
$validator->errors()->add('target_lane', 'target_lane is verplicht bij een niet-leeg target_stage_id.');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the event_id the candidate move lands on so the
|
|
* StageActiveOnEvent and WithinEventBounds rules can validate
|
|
* against a concrete event window.
|
|
*
|
|
* For flat events: stage.event_id is the answer.
|
|
* For festivals: walk stage_days for target_stage_id and find the
|
|
* sub-event whose [start, end] contains target_start_at.
|
|
*/
|
|
private function resolveTargetEventId(): ?string
|
|
{
|
|
$stageId = $this->input('target_stage_id');
|
|
$startAt = $this->input('target_start_at');
|
|
if (! is_string($stageId) || ! is_string($startAt)) {
|
|
return null;
|
|
}
|
|
|
|
$start = CarbonImmutable::parse($startAt);
|
|
$stage = Stage::query()->find($stageId);
|
|
if ($stage === null) {
|
|
return null;
|
|
}
|
|
|
|
$match = StageDay::query()
|
|
->where('stage_id', $stage->id)
|
|
->join('events', 'events.id', '=', 'stage_days.event_id')
|
|
->where('events.start_date', '<=', $start->toDateString())
|
|
->where('events.end_date', '>=', $start->toDateString())
|
|
->orderBy('events.start_date', 'desc')
|
|
->limit(1)
|
|
->value('stage_days.event_id');
|
|
|
|
return is_string($match) ? $match : $stage->event_id;
|
|
}
|
|
}
|