'sqlite', 'database.connections.sqlite.database' => ':memory:', 'cache.default' => 'array', 'services.people_counting.url' => 'https://camera.test/api/people-counting', 'app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]); DB::purge('sqlite'); Schema::create('venues', function (Blueprint $t) { $t->id(); $t->string('name'); }); Schema::create('users', function (Blueprint $t) { $t->id(); }); (require database_path('migrations/2026_09_29_120000_create_camera_monitoring_tables.php'))->up(); (require database_path('migrations/2026_10_08_120000_add_repeat_camera_notifications.php'))->up(); CameraDevice::query()->update(['monitor_enabled' => false]); CameraMonitorSetting::current()->update(['notification_start_time' => '00:00', 'notification_end_time' => '23:59', 'recipient_emails' => ['operator@example.test'], 'repeat_notification_enabled' => true, 'repeat_notification_interval_hours' => 24]); Carbon::setTestNow(Carbon::parse('2026-10-08 10:00:00', 'Asia/Shanghai')); Mail::shouldReceive('raw')->andReturnNull(); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } private function camera(string $ip = '192.0.2.1'): CameraDevice { return CameraDevice::create(['ip' => $ip, 'name' => '测试摄像头', 'channel' => 1, 'monitor_enabled' => true, 'notification_enabled' => true]); } private function sync(CameraDevice $camera, string $status = 'NETWORK_DOWN', bool $manual = true): void { Http::swap(new \Illuminate\Http\Client\Factory()); Http::fake(['*' => Http::response(['devices' => [[ 'ip' => $camera->ip, 'channel' => 1, 'sdkStatus' => in_array($status, ['PUSH_STALE', 'ONLINE_PUSHING']) ? 'ONLINE' : 'LOGIN_FAILED', 'healthStatus' => $status, 'manualActionRequired' => $manual, 'lastPdcAt' => $status === 'ONLINE_PUSHING' ? now()->toIso8601String() : null, ]]])]); $this->assertSame(0, Artisan::call('camera-monitor:sync')); } public function test_repeats_after_24_hours_even_if_acknowledged_and_resolves_only_on_push(): void { $camera = $this->camera(); $this->sync($camera); $alert = CameraAlert::where('camera_device_id', $camera->id)->firstOrFail(); $this->assertSame(1, $alert->notifications()->count()); $alert->update(['status' => 'ACKNOWLEDGED']); Carbon::setTestNow(now()->addHours(23)); $this->sync($camera); $this->assertSame(1, $alert->notifications()->count()); Carbon::setTestNow(now()->addHour()); $this->sync($camera); $this->assertSame(2, $alert->notifications()->count()); $this->sync($camera); $this->assertSame(2, $alert->notifications()->count()); $this->sync($camera, 'PUSH_STALE'); $this->assertSame('ACKNOWLEDGED', $alert->fresh()->status); $this->sync($camera, 'ONLINE_PUSHING', false); $this->assertSame('RESOLVED', $alert->fresh()->status); $this->assertSame(2, $alert->notifications()->count()); $this->sync($camera); $this->assertSame(2, CameraAlert::where('camera_device_id', $camera->id)->count()); } public function test_disabled_repeat_and_notification_window_are_respected(): void { $camera = $this->camera(); $this->sync($camera); $alert = CameraAlert::where('camera_device_id', $camera->id)->firstOrFail(); CameraMonitorSetting::current()->update(['repeat_notification_enabled' => false]); Carbon::setTestNow(now()->addDays(2)); $this->sync($camera); $this->assertSame(1, $alert->notifications()->count()); CameraMonitorSetting::current()->update(['repeat_notification_enabled' => true, 'notification_start_time' => '11:00', 'notification_end_time' => '19:00']); $this->sync($camera); $this->assertSame(1, $alert->notifications()->count()); Carbon::setTestNow(now()->addHour()); $this->sync($camera, 'NETWORK_DOWN', false); $this->assertSame(1, $alert->notifications()->count()); $this->sync($camera); $this->assertSame(2, $alert->notifications()->count()); } public function test_recovery_does_not_close_other_cameras_in_the_same_venue(): void { $venue = DB::table('venues')->insertGetId(['name' => '同一场馆']); $first = $this->camera(); $second = $this->camera('192.0.2.2'); $first->update(['venue_id' => $venue]); $second->update(['venue_id' => $venue]); $this->sync($first); $this->sync($second); $this->sync($first, 'ONLINE_PUSHING', false); $this->assertSame('RESOLVED', $first->alerts()->first()->status); $this->assertSame('OPEN', $second->alerts()->first()->status); } public function test_failed_recipient_retries_without_resending_to_successful_recipient(): void { CameraMonitorSetting::current()->update(['recipient_emails' => ['operator@example.test', 'second@example.test']]); $mailer = \Mockery::mock(); $calls = 0; $mailer->shouldReceive('raw')->andReturnUsing(function () use (&$calls) { if (++$calls === 2) throw new \RuntimeException('模拟发送失败'); }); Mail::swap($mailer); $camera = $this->camera(); $this->sync($camera); $alert = $camera->alerts()->first(); $this->assertSame(1, $alert->notifications()->where('send_status', 'sent')->count()); $this->sync($camera); $this->assertSame(3, $calls); $this->assertSame(2, $alert->notifications()->where('send_status', 'sent')->count()); } public function test_email_records_filter_paginate_and_keep_disabled_camera_details(): void { $camera = $this->camera(); $this->sync($camera); $alert = $camera->alerts()->first(); $failed = $alert->notifications()->create(['channel' => 'email', 'receiver' => 'failed@example.test', 'send_status' => 'failed', 'error_message' => 'SMTP timeout']); $camera->delete(); $controller = new \App\Http\Controllers\Api\CameraMonitoringController(); $admin = new \App\Models\User(); $admin->role = 'super_admin'; $request = \Illuminate\Http\Request::create('/api/camera-email-records', 'GET', ['page_size' => 1]); $request->setUserResolver(fn () => $admin); $data = $controller->emailRecords($request)->getData(true); $this->assertSame(2, $data['total']); $this->assertCount(1, $data['data']); $this->assertSame($failed->id, $data['data'][0]['id']); $this->assertSame($camera->ip, $data['data'][0]['alert']['camera']['ip']); $request->query->set('send_status', 'sent'); $data = $controller->emailRecords($request)->getData(true); $this->assertSame(1, $data['total']); $this->assertSame('sent', $data['data'][0]['send_status']); } public function test_email_records_require_platform_admin(): void { $request = \Illuminate\Http\Request::create('/api/camera-email-records'); $request->setUserResolver(fn () => new class { public function isSuperAdmin(): bool { return false; } }); try { (new \App\Http\Controllers\Api\CameraMonitoringController())->emailRecords($request); $this->fail('Non-admin must not access recipient and SMTP details'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403, $e->getStatusCode()); } } public function test_alert_history_preserves_confirmation_time_and_cannot_reopen_recovered_alert(): void { $camera = $this->camera(); $this->sync($camera); $alert = $camera->alerts()->first(); $admin = new \App\Models\User(); $admin->role = 'super_admin'; $admin->id = DB::table('users')->insertGetId([]); $request = \Illuminate\Http\Request::create('/api/camera-alerts'); $request->setUserResolver(fn () => $admin); $controller = new \App\Http\Controllers\Api\CameraMonitoringController(); $controller->acknowledge($request, $alert); $confirmed = $alert->fresh()->acknowledged_at->toIso8601String(); Carbon::setTestNow(now()->addHour()); $controller->acknowledge($request, $alert); $this->assertSame($confirmed, $alert->fresh()->acknowledged_at->toIso8601String()); $this->sync($camera, 'ONLINE_PUSHING', false); $controller->acknowledge($request, $alert); $this->assertSame('RESOLVED', $alert->fresh()->status); $camera->delete(); $data = $controller->alerts($request)->getData(true); $this->assertSame(1, $data['total']); $this->assertSame($camera->ip, $data['data'][0]['camera']['ip']); $this->assertNotEmpty($data['data'][0]['acknowledged_at']); $this->assertNotEmpty($data['data'][0]['recovered_at']); $request->query->set('status', 'OPEN'); $this->assertSame(0, $controller->alerts($request)->getData(true)['total']); } }