test: regression guards for listener registration uniqueness + always-present binary tags

Drie regression-tests die de klasse fouten uit PR-2 nazorg empirisch
voorkomen:

1. test_authenticated_listener_registered_exactly_once
2. test_token_authenticated_listener_registered_exactly_once
3. test_job_processing_tag_listener_registered_exactly_once
   — vangen OBS-8 patroon (auto-discovery + explicit listen samen) plus
   accidentally-removed registrations door toekomstige refactors. Walk
   Event::getRawListeners() en faalt met count != 1 met een duidelijke
   message ("auto-discovery re-enabled? OR explicit Event::listen
   missing?"). Empirisch geverifieerd: zowel duplicate als missing
   registratie wordt gevangen.

4. test_impersonation_active_tag_invariant_on_captured_events
   — RFC §3.6 binary signal invariant op een echte HTTP request flow.
   Vangt regressie waar de baseline-tag-binding verdwijnt.

BACKLOG.md OBS-8 entry toegevoegd en gemarkeerd als Resolved met
verwijzing naar de drie commits van deze sessie + architecturaal
pattern (explicit > implicit voor observability-kritische bindings).

Test count 1545 to 1549. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:35:11 +02:00
parent a939820122
commit dee140193e
2 changed files with 212 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Events\TokenAuthenticated;
use Sentry\ClientBuilder;
use Sentry\Event as SentryEvent;
use Sentry\EventHint;
use Sentry\SentrySdk;
use Sentry\State\Hub;
use Tests\TestCase;
/**
* Regression guards for OBS-8 observability listener registratie-discipline.
*
* Catches:
* 1. Double-registration via auto-discovery + explicit listen combinatie.
* {@see bootstrap/app.php} disables auto-discovery via
* `->withEvents(discover: false)`; this test fails if a future refactor
* re-enables it without updating the explicit registrations.
* 2. Listener-registratie verdwijnt door refactor van AppServiceProvider.
* 3. Class-string registratie ipv array-callable (verbergt method-binding
* in event:list output, en breekt voor listener-classes met meerdere
* methodes).
*
* Plus the always-present binary-tag invariant for impersonation.active.
*
* RFC-WS-7-OBSERVABILITY.md §3.6, BACKLOG OBS-8.
*/
final class EventListenerRegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_listener_registered_exactly_once(): void
{
$this->assertListenerRegisteredOnce(
Authenticated::class,
\App\Listeners\Observability\AuthScopeContextListener::class,
'handle',
);
}
public function test_token_authenticated_listener_registered_exactly_once(): void
{
$this->assertListenerRegisteredOnce(
TokenAuthenticated::class,
\App\Listeners\Observability\AuthScopeContextListener::class,
'handleTokenAuthenticated',
);
}
public function test_job_processing_tag_listener_registered_exactly_once(): void
{
$this->assertListenerRegisteredOnce(
JobProcessing::class,
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
'handle',
);
}
public function test_impersonation_active_tag_invariant_on_captured_events(): void
{
$this->seed(RoleSeeder::class);
// Install a recording before_send hook so we can inspect the
// captured event's tags on the live HTTP path. Same pattern as
// AuthScopeBindingHttpFlowTest.
$captured = null;
$clientBuilder = ClientBuilder::create([
'dsn' => 'https://test@localhost/1',
'environment' => 'testing',
'release' => 'crewli-api@test',
'send_default_pii' => false,
'traces_sample_rate' => 0.0,
'profiles_sample_rate' => 0.0,
'before_send' => static function (SentryEvent $event, ?EventHint $hint = null) use (&$captured): ?SentryEvent {
$captured = $event;
return null;
},
]);
SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient()));
$user = User::factory()->create();
$user->assignRole('org_admin');
\Illuminate\Support\Facades\Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])
->get('_obs_invariant_check', static fn () => throw new \RuntimeException('invariant'));
$token = $user->createToken('regression')->plainTextToken;
$this->withHeaders(['Authorization' => 'Bearer '.$token])->getJson('/_obs_invariant_check');
$this->assertNotNull($captured, 'Sentry event must be captured');
$tags = $captured->getTags();
$this->assertArrayHasKey(
'impersonation.active',
$tags,
'impersonation.active MUST be present on every authenticated captured event (RFC §3.6 binary signal invariant).',
);
$this->assertContains(
$tags['impersonation.active'],
['true', 'false'],
'impersonation.active value must be the binary string "true" or "false".',
);
}
/**
* Asserts exactly one registration for the given (event, listener,
* method) triple. Catches both zero registrations (refactor accidentally
* dropped the call) and multiple registrations (auto-discovery +
* explicit listen combined).
*
* Uses {@see Event::getRawListeners()} which returns the dispatcher's
* internal listeners array verbatim each `Event::listen()` call
* appends one entry. Class-string and array-callable forms are both
* accepted as valid registration shapes.
*/
private function assertListenerRegisteredOnce(
string $eventClass,
string $listenerClass,
string $method,
): void {
$rawListeners = Event::getRawListeners()[$eventClass] ?? [];
$matches = 0;
foreach ($rawListeners as $listener) {
if (is_array($listener) && count($listener) === 2 && $listener[0] === $listenerClass && $listener[1] === $method) {
$matches++;
continue;
}
if (is_string($listener)) {
if ($listener === $listenerClass.'@'.$method) {
$matches++;
continue;
}
if ($listener === $listenerClass && $method === 'handle') {
$matches++;
}
}
}
$this->assertSame(
1,
$matches,
sprintf(
'Expected exactly 1 registration for %s::%s on event %s, found %d. '
.'Either auto-discovery is double-registering (OBS-8 regression — '
.'check ->withEvents(discover: false) in bootstrap/app.php) or the '
.'explicit Event::listen() call in AppServiceProvider::boot() is missing.',
$listenerClass,
$method,
$eventClass,
$matches,
),
);
}
}