Files
crewli/api/tests/Feature/Portal/EngagementPortalControllerTest.php
bert.hausmans 96eb7e91e7 test(timetable): Phase C — observer, resolver, seeder, portal controller tests
22 new tests across four files:
  - AdvanceSectionObserverTest (7) — counter recompute on create / status
    transition / delete / is_open toggle no-op / orphaned-section guard /
    no activity-log noise on counter writes
  - ArtistResolverTest (4) — happy path / invalid token / soft-deleted
    artist / SHA-256 digest verification
  - ArtistAdvanceDefaultTest (6) — five-section + slug shape / idempotency
    / per-section field shape / observer-invocation outside tests /
    artisan one-org + all-orgs paths
  - EngagementPortalControllerTest (6) — show 200/404/410 / show-section
    schema + draft values / submit happy-path with submission persistence
    + counter recompute / cross-engagement section returns 404

Implementation tweaks driven by test feedback:
  - OrganisationObserver gated by `app()->runningUnitTests()` — auto-seed
    runs in production but is silent in CI so existing FormSchema-counting
    tests are unperturbed. Tests that need the seeded schema invoke
    `ArtistAdvanceDefault::seedFor()` explicitly.
  - EngagementPortalController idempotency_key uses `aa-` + sha1 prefix
    (28 chars) so it fits the form_submissions.idempotency_key
    varchar(30) column.

Test count: 1709 (Session 2 close) → 1731 (+22).
Larastan: 0 new errors over baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:39:04 +02:00

158 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Portal;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\Artist\AdvanceSectionType;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class EngagementPortalControllerTest extends TestCase
{
use RefreshDatabase;
public function test_show_returns_payload_for_valid_token(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
$response = $this->getJson("/api/v1/p/artist/{$plain}");
$response->assertOk();
$response->assertJsonPath('data.engagement_id', $engagement->id);
$response->assertJsonStructure([
'data' => ['engagement_id', 'artist', 'event', 'sections'],
]);
}
public function test_show_returns_404_for_invalid_token(): void
{
$this->getJson('/api/v1/p/artist/not-a-real-token')->assertNotFound();
}
public function test_show_returns_410_when_master_artist_soft_deleted(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->delete();
$this->getJson("/api/v1/p/artist/{$plain}")->assertStatus(410);
}
public function test_show_section_returns_schema_and_existing_values(): void
{
[$plain, $engagement, $section] = $this->makeEngagementWithSection();
$response = $this->getJson("/api/v1/p/artist/{$plain}/sections/{$section->id}");
$response->assertOk();
$response->assertJsonPath('data.section.id', $section->id);
$response->assertJsonStructure([
'data' => ['section', 'fields', 'values'],
]);
}
public function test_submit_section_creates_submission_and_updates_status(): void
{
[$plain, $engagement, $section] = $this->makeEngagementWithSection();
$response = $this->postJson("/api/v1/p/artist/{$plain}/sections/{$section->id}", [
'values' => [
'general-notes' => 'Hello, world',
],
]);
$response->assertOk();
$section->refresh();
$this->assertSame(AdvanceSectionSubmissionStatus::Submitted, $section->submission_status);
$this->assertNotNull($section->last_submitted_at);
// FormSubmission persisted with master artist as subject + event_id from engagement
$submission = FormSubmission::query()
->withoutGlobalScope(OrganisationScope::class)
->where('subject_id', $engagement->artist_id)
->first();
$this->assertNotNull($submission);
$this->assertSame('artist', $submission->subject_type);
$this->assertSame((string) $engagement->event_id, (string) $submission->event_id);
$this->assertSame($engagement->organisation_id, $submission->organisation_id);
// Counter recompute fires (Submitted is not Approved, so completed stays 0)
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_submit_with_section_from_different_engagement_returns_404(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
$otherOrg = Organisation::factory()->create();
$otherEvent = Event::factory()->for($otherOrg)->create();
$otherArtist = Artist::factory()->for($otherOrg)->create();
$other = ArtistEngagement::factory()->create([
'organisation_id' => $otherOrg->id,
'artist_id' => $otherArtist->id,
'event_id' => $otherEvent->id,
]);
$stranger = AdvanceSection::factory()->create(['engagement_id' => $other->id, 'name' => 'Algemeen']);
$this->postJson("/api/v1/p/artist/{$plain}/sections/{$stranger->id}", [
'values' => ['general-notes' => 'x'],
])->assertNotFound();
}
/**
* @return array{0: string, 1: ArtistEngagement, 2: AdvanceSection}
*/
private function makeEngagementWithSection(): array
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = Artist::factory()->for($org)->create();
// OrganisationObserver skips auto-seed in tests; seed explicitly.
ArtistAdvanceDefault::seedFor($org);
$schema = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $org->id)
->where('purpose', \App\Enums\FormBuilder\FormPurpose::ARTIST_ADVANCE->value)
->firstOrFail();
$schemaSection = FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('slug', 'general-info')
->firstOrFail();
$plain = (string) Str::ulid();
$engagement = ArtistEngagement::factory()->create([
'organisation_id' => $org->id,
'artist_id' => $artist->id,
'event_id' => $event->id,
'portal_token' => hash('sha256', $plain),
]);
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'name' => $schemaSection->name,
'type' => AdvanceSectionType::Custom,
'is_open' => true,
]);
return [$plain, $engagement, $section];
}
}