Files
portal/app/tests/Feature/AdminIncidentsIndexTest.php
T
Дмитрий 14dc317e2b phase2(admin-incidents): GET /api/admin/incidents + AdminIncidentsView API (этап 4/5)
Чтение incidents_log с фильтрами type/severity/unresolved_only + summary
(open/investigating/rkn_pending/total_unresolved).

Backend (AdminIncidentsController::index):
- ORDER BY started_at DESC. Filters: type, severity, unresolved_only=true.
- Derived: incident_id (INC-YYYY-MMDD-NNNN), status (resolved_at!=null →
  resolved; detected_at!=null → investigating; иначе open),
  affected_tenants_count из BIGINT[] (parsePgArray для '{1,2,3}'),
  rkn_deadline_at = detected_at+24h для data_breach без notification.
- summary: open/investigating/rkn_pending/total_unresolved.

Pest +11 (AdminIncidentsIndexTest):
- пустой / incident_id формат / derive status / filter type+severity /
  unresolved_only / ORDER BY started_at DESC / rkn_deadline +24h для
  data_breach / non-data_breach без deadline / summary.rkn_pending /
  limit+offset.
- Quirk: saas_admin_users.full_name (не first/last) + нет updated_at.

Frontend:
- api/admin.ts::listAdminIncidents — типизированный helper.
- AdminIncidentsView: унифицированный IncidentRow (mock-category ↔
  API-type, mock-title ↔ API-summary). Reactive rowsState+stats default
  = MOCK; loadIncidents() async на onMounted; fetchError + warning
  alert + MOCK fallback; reload-btn. РКН pending chip учитывает оба
  pdn_breach/data_breach.

Vitest +5:
- listAdminIncidents на mount / replace state+stats + rkn_deadline /
  reject → fetchError+alert+fallback / reload-btn x2 / РКН pending chip
  виден для data_breach без notification.

PHPStan baseline регенерирован. cspell-glossary +MMDD.

Регресс:
- Lint+type-check+format passed.
- Vitest 305/305 за 20.59 сек (+5 от 300).
- Vite build 1.05 сек.
- Pint + PHPStan passed.
- Pest 248/248 за 28.02 сек (+11 от 237, 951 assertion).

Реестр v1.67→v1.68 / CLAUDE.md v1.58→v1.59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:38:34 +03:00

169 lines
6.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('incidents_log')->delete();
// Создаём минимальный saas_admin_user для FK.
$this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'admin-'.bin2hex(random_bytes(3)).'@test',
'full_name' => 'Admin Test',
'password_hash' => bcrypt('test'),
'is_active' => true,
'role' => 'support',
'created_at' => now(),
]);
});
function makeIncident(int $adminId, array $overrides = []): int
{
$started = $overrides['started_at'] ?? now();
$detected = $overrides['detected_at'] ?? $started;
return (int) DB::table('incidents_log')->insertGetId(array_merge([
'type' => 'service_outage',
'severity' => 'medium',
'started_at' => $started,
'detected_at' => $detected,
'resolved_at' => null,
'summary' => 'Test incident',
'created_by_admin_id' => $adminId,
'created_at' => now(),
], $overrides));
}
test('GET /api/admin/incidents 200 + пустой', function () {
$r = $this->getJson('/api/admin/incidents');
$r->assertStatus(200);
expect($r->json('incidents'))->toBe([]);
expect($r->json('total'))->toBe(0);
expect($r->json('summary.total_unresolved'))->toBe(0);
});
test('GET /api/admin/incidents возвращает ID + поля', function () {
$id = makeIncident($this->adminId, [
'type' => 'service_outage',
'severity' => 'high',
'summary' => 'API timeout — рост 502',
]);
$r = $this->getJson('/api/admin/incidents');
expect($r->json('total'))->toBe(1);
$row = $r->json('incidents.0');
expect($row['id'])->toBe($id);
expect($row['type'])->toBe('service_outage');
expect($row['severity'])->toBe('high');
expect($row['summary'])->toBe('API timeout — рост 502');
expect($row['incident_id'])->toMatch('/^INC-\d{4}-\d{4}-\d{4}$/');
});
test('GET /api/admin/incidents derive статус: resolved/investigating', function () {
makeIncident($this->adminId, ['summary' => 'Inv 1']); // detected_at=started → investigating
makeIncident($this->adminId, [
'summary' => 'Resolved 1',
'resolved_at' => now()->addHour(),
]);
$r = $this->getJson('/api/admin/incidents');
$byStatus = collect($r->json('incidents'))->groupBy('status');
expect($byStatus->get('investigating'))->toHaveCount(1);
expect($byStatus->get('resolved'))->toHaveCount(1);
});
test('GET /api/admin/incidents фильтр по type', function () {
makeIncident($this->adminId, ['type' => 'service_outage', 'summary' => 'A']);
makeIncident($this->adminId, ['type' => 'data_breach', 'summary' => 'B']);
makeIncident($this->adminId, ['type' => 'billing_failure', 'summary' => 'C']);
$r = $this->getJson('/api/admin/incidents?type=data_breach');
expect($r->json('total'))->toBe(1);
expect($r->json('incidents.0.summary'))->toBe('B');
});
test('GET /api/admin/incidents фильтр по severity', function () {
makeIncident($this->adminId, ['severity' => 'critical', 'summary' => 'crit']);
makeIncident($this->adminId, ['severity' => 'medium', 'summary' => 'med']);
$r = $this->getJson('/api/admin/incidents?severity=critical');
expect($r->json('total'))->toBe(1);
expect($r->json('incidents.0.summary'))->toBe('crit');
});
test('GET /api/admin/incidents unresolved_only=true фильтрует только нерешённые', function () {
makeIncident($this->adminId, ['summary' => 'open']);
makeIncident($this->adminId, [
'summary' => 'closed',
'resolved_at' => now()->addHour(),
]);
$r = $this->getJson('/api/admin/incidents?unresolved_only=true');
expect($r->json('total'))->toBe(1);
expect($r->json('incidents.0.summary'))->toBe('open');
});
test('GET /api/admin/incidents сортирует по started_at DESC', function () {
makeIncident($this->adminId, ['summary' => 'old', 'started_at' => now()->subDays(5)]);
makeIncident($this->adminId, ['summary' => 'new', 'started_at' => now()->subMinutes(5)]);
makeIncident($this->adminId, ['summary' => 'mid', 'started_at' => now()->subDays(1)]);
$r = $this->getJson('/api/admin/incidents');
$summaries = collect($r->json('incidents'))->pluck('summary')->all();
expect($summaries)->toBe(['new', 'mid', 'old']);
});
test('GET /api/admin/incidents data_breach без rkn_notified_at имеет deadline +24ч', function () {
$detected = now()->subHours(2);
makeIncident($this->adminId, [
'type' => 'data_breach',
'detected_at' => $detected,
'started_at' => $detected,
'summary' => 'PDN leak',
]);
$r = $this->getJson('/api/admin/incidents');
$row = $r->json('incidents.0');
expect($row['rkn_notified'])->toBeFalse();
expect($row['rkn_deadline_at'])->toBeString();
});
test('GET /api/admin/incidents non-data_breach НЕ имеет rkn_deadline', function () {
makeIncident($this->adminId, ['type' => 'service_outage']);
$r = $this->getJson('/api/admin/incidents');
expect($r->json('incidents.0.rkn_deadline_at'))->toBeNull();
});
test('GET /api/admin/incidents summary.rkn_pending считает PDN-breach без notification', function () {
// 2 PDN-breach: одна с notification, одна без
makeIncident($this->adminId, [
'type' => 'data_breach',
'rkn_notified_at' => now(),
'summary' => 'breach1 notified',
]);
makeIncident($this->adminId, [
'type' => 'data_breach',
'summary' => 'breach2 pending',
]);
// service_outage не считается
makeIncident($this->adminId, ['type' => 'service_outage', 'summary' => 'outage']);
$r = $this->getJson('/api/admin/incidents');
expect($r->json('summary.rkn_pending'))->toBe(1);
});
test('GET /api/admin/incidents limit + offset', function () {
foreach (range(1, 5) as $i) {
makeIncident($this->adminId, ['summary' => 'I'.$i, 'started_at' => now()->subMinutes($i)]);
}
$r = $this->getJson('/api/admin/incidents?limit=2&offset=1');
expect($r->json('total'))->toBe(5);
expect(count($r->json('incidents')))->toBe(2);
});