*/ private static array $captured = []; protected function setUp(): void { parent::setUp(); $this->seed(RoleSeeder::class); self::$captured = []; // Wire a real Sentry client whose before_send records events into // the static buffer and returns null (drops, never networked). $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, 'ignore_exceptions' => [ ValidationException::class, \Illuminate\Auth\AuthenticationException::class, AuthorizationException::class, ], 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent { self::$captured[] = ['event' => $event, 'hint' => $hint]; return null; }, ]); $hub = new Hub($clientBuilder->getClient()); SentrySdk::setCurrentHub($hub); // Test-only routes that exercise each branch of the // ignore_exceptions / before_send / capture pipeline. Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { Route::get('_obs_runtime', static fn () => throw new RuntimeException('boom')) ->name('test.obs.runtime'); Route::get('_obs_validation', static function (): never { throw ValidationException::withMessages(['email' => 'required']); })->name('test.obs.validation'); Route::get('_obs_404', static fn () => throw new NotFoundHttpException('nope')) ->name('test.obs.404'); Route::get('_obs_403', static fn () => throw new AuthorizationException('denied')) ->name('test.obs.403'); }); } private function actAsOrgAdmin(): void { $org = Organisation::factory()->create(); $user = User::factory()->create(); $org->users()->attach($user, ['role' => 'org_admin']); $user->assignRole('org_admin'); Sanctum::actingAs($user); } public function test_runtime_exception_from_controller_is_captured(): void { $this->actAsOrgAdmin(); $this->getJson('/_obs_runtime')->assertStatus(500); $this->assertCount(1, self::$captured, 'expected exactly one captured event'); $event = self::$captured[0]['event']; $exceptions = $event->getExceptions(); $this->assertNotEmpty($exceptions); $this->assertSame(RuntimeException::class, $exceptions[0]->getType()); $this->assertSame('boom', $exceptions[0]->getValue()); } public function test_validation_exception_is_not_captured(): void { $this->actAsOrgAdmin(); $this->getJson('/_obs_validation')->assertStatus(422); $this->assertCount(0, self::$captured); } public function test_not_found_http_exception_is_not_captured(): void { $this->actAsOrgAdmin(); $this->getJson('/_obs_404')->assertStatus(404); $this->assertCount(0, self::$captured); } public function test_authorization_exception_is_not_captured(): void { $this->actAsOrgAdmin(); $this->getJson('/_obs_403')->assertStatus(403); $this->assertCount(0, self::$captured); } public function test_runtime_exception_carries_request_context(): void { $this->actAsOrgAdmin(); $this->getJson('/_obs_runtime')->assertStatus(500); $this->assertCount(1, self::$captured); $tags = self::$captured[0]['event']->getTags(); // BindSentryRouteContext should have set these on the scope // before the exception fired in the controller. $this->assertSame('api', $tags['app'] ?? null); $this->assertSame('GET', $tags['http.method'] ?? null); } }