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>
This commit is contained in:
2026-05-08 22:39:04 +02:00
parent e26da4fb42
commit 96eb7e91e7
6 changed files with 536 additions and 1 deletions

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class AdvanceSectionObserverTest extends TestCase
{
use RefreshDatabase;
public function test_create_increments_total_count(): void
{
$engagement = $this->makeEngagement(['advancing_completed_count' => 0, 'advancing_total_count' => 0]);
AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$fresh = $engagement->fresh();
$this->assertSame(1, (int) $fresh->advancing_total_count);
$this->assertSame(0, (int) $fresh->advancing_completed_count);
}
public function test_status_transition_to_approved_increments_completed(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'submission_status' => AdvanceSectionSubmissionStatus::Pending,
]);
$section->submission_status = AdvanceSectionSubmissionStatus::Approved->value;
$section->save();
$fresh = $engagement->fresh();
$this->assertSame(1, (int) $fresh->advancing_total_count);
$this->assertSame(1, (int) $fresh->advancing_completed_count);
}
public function test_status_transition_away_from_approved_decrements_completed(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'submission_status' => AdvanceSectionSubmissionStatus::Approved,
]);
$this->assertSame(1, (int) $engagement->fresh()->advancing_completed_count);
$section->submission_status = AdvanceSectionSubmissionStatus::Pending->value;
$section->save();
$this->assertSame(0, (int) $engagement->fresh()->advancing_completed_count);
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_delete_decrements_total(): void
{
$engagement = $this->makeEngagement();
$sectionA = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$sectionB = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$this->assertSame(2, (int) $engagement->fresh()->advancing_total_count);
$sectionA->delete();
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_is_open_toggle_does_not_recompute(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'is_open' => false,
]);
$startTotal = (int) $engagement->fresh()->advancing_total_count;
$startCompleted = (int) $engagement->fresh()->advancing_completed_count;
$section->is_open = true;
$section->save();
$this->assertSame($startTotal, (int) $engagement->fresh()->advancing_total_count);
$this->assertSame($startCompleted, (int) $engagement->fresh()->advancing_completed_count);
}
public function test_recompute_skips_when_engagement_already_force_deleted(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->id)
->forceDelete();
// Force-deleting via raw query bypasses cascade observer; section
// is now orphaned. The observer should no-op rather than crash
// when the parent is gone.
$section->delete();
$this->expectNotToPerformAssertions();
}
public function test_counter_writes_do_not_emit_activity(): void
{
$engagement = $this->makeEngagement();
$logsBefore = \Spatie\Activitylog\Models\Activity::query()
->where('subject_id', $engagement->id)
->count();
AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$logsAfter = \Spatie\Activitylog\Models\Activity::query()
->where('subject_id', $engagement->id)
->count();
$this->assertSame($logsBefore, $logsAfter, 'Counter sync must not emit activity-log entries on the engagement.');
}
/**
* @param array<string, mixed> $overrides
*/
private function makeEngagement(array $overrides = []): ArtistEngagement
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = Artist::factory()->for($org)->create();
return ArtistEngagement::factory()->create(array_merge([
'organisation_id' => $org->id,
'artist_id' => $artist->id,
'event_id' => $event->id,
], $overrides));
}
}