You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
180 lines
9.3 KiB
180 lines
9.3 KiB
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\{CameraAlert, CameraDevice, CameraMonitorSetting};
|
|
use Illuminate\Support\Facades\{Artisan, DB, Http, Mail, Schema};
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Carbon;
|
|
use Tests\TestCase;
|
|
|
|
class CameraNotificationTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['database.default' => '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']);
|
|
}
|
|
}
|