style+done(p2): pint formatting + P2 plan DONE marker

This commit is contained in:
Дмитрий
2026-05-22 18:48:03 +03:00
parent 37f5a321e6
commit 5df34a61eb
16 changed files with 164 additions and 141 deletions
@@ -27,13 +27,13 @@ class IncidentsWatchFailures extends Command
public function handle(): int
{
$windowMinutes = (int) $this->option('window');
$threshold = (int) $this->option('threshold');
$dedupMinutes = (int) $this->option('dedup-window');
$windowMinutes = (int) $this->option('window');
$threshold = (int) $this->option('threshold');
$dedupMinutes = (int) $this->option('dedup-window');
$since = Carbon::now()->subMinutes($windowMinutes);
$since = Carbon::now()->subMinutes($windowMinutes);
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
$now = Carbon::now();
$now = Carbon::now();
// Группируем упавшие (ещё не resolved) джобы за окно по сигнатуре
$groups = DB::table('failed_webhook_jobs')
@@ -46,6 +46,7 @@ class IncidentsWatchFailures extends Command
if ($groups->isEmpty()) {
$this->info('No failure spikes detected.');
return self::SUCCESS;
}
@@ -57,39 +58,41 @@ class IncidentsWatchFailures extends Command
if ($adminId === null) {
$this->error('No active saas_admin_users found — cannot create incidents_log rows.');
return self::FAILURE;
}
$created = 0;
foreach ($groups as $group) {
$sig = $group->sig;
$sig = $group->sig;
$count = (int) $group->cnt;
// Дедупликация: есть ли уже открытый инцидент с такой сигнатурой?
$alreadyOpen = DB::table('incidents_log')
->where('summary', 'like', '%' . addcslashes(substr($sig, 0, 80), '%_\\') . '%')
->where('summary', 'like', '%'.addcslashes(substr($sig, 0, 80), '%_\\').'%')
->whereNull('resolved_at')
->where('detected_at', '>=', $dedupAt)
->exists();
if ($alreadyOpen) {
$this->line("Skipping (dedup): {$sig}");
continue;
}
DB::table('incidents_log')->insert([
'type' => 'other',
'severity' => 'high',
'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. "
. "Сигнатура: {$sig}",
'root_cause' => null,
'started_at' => $since,
'detected_at' => $now,
'resolved_at' => null,
'type' => 'other',
'severity' => 'high',
'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. "
."Сигнатура: {$sig}",
'root_cause' => null,
'started_at' => $since,
'detected_at' => $now,
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => $now,
'updated_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$created++;
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Models\Project;
use App\Models\SaasAdminAuditLog;
@@ -148,13 +148,13 @@ final class AdminSupplierIntegrationController extends Controller
]);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.manual_queue_resolved',
'target_type' => 'manual_queue_item',
'target_id' => $row->id,
'payload_after' => ['external_id' => $found, 'platform' => $row->platform],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.manual_queue_resolved',
'target_type' => 'manual_queue_item',
'target_id' => $row->id,
'payload_after' => ['external_id' => $found, 'platform' => $row->platform],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
@@ -184,14 +184,14 @@ final class AdminSupplierIntegrationController extends Controller
);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.export_mode_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prevMode !== null ? ['mode' => $prevMode] : null,
'payload_after' => ['mode' => $data['mode']],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.export_mode_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prevMode !== null ? ['mode' => $prevMode] : null,
'payload_after' => ['mode' => $data['mode']],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
@@ -283,14 +283,14 @@ final class AdminSupplierIntegrationController extends Controller
}
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.projects_destroyed',
'target_type' => 'supplier_projects_bulk',
'target_id' => null,
'payload_before' => ['ids' => $data['ids']],
'payload_after' => ['deleted' => $deleted, 'failures_count' => count($failures)],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.projects_destroyed',
'target_type' => 'supplier_projects_bulk',
'target_id' => null,
'payload_before' => ['ids' => $data['ids']],
'payload_after' => ['deleted' => $deleted, 'failures_count' => count($failures)],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
@@ -132,13 +132,13 @@ class SupplierWebhookController extends Controller
try {
DB::table('webhook_log')->insert([
'tenant_id' => null,
'tenant_id' => null,
'raw_payload' => '{}',
'source' => 'supplier',
'status' => $status,
'lead_id' => $leadId,
'ip_address' => $request->ip(),
'created_at' => now(),
'source' => 'supplier',
'status' => $status,
'lead_id' => $leadId,
'ip_address' => $request->ip(),
'created_at' => now(),
]);
} catch (\Throwable) {
// Never let logging failure break the primary response
+3 -1
View File
@@ -1,4 +1,6 @@
<?php declare(strict_types=1);
<?php
declare(strict_types=1);
namespace App\Services\Audit;
+4 -4
View File
@@ -17,7 +17,7 @@ use Illuminate\Support\Facades\DB;
class ProjectService
{
public function __construct(
private readonly OperationsLogger $ops = new OperationsLogger(),
private readonly OperationsLogger $ops = new OperationsLogger,
) {}
public function update(Project $project, array $data): Project
@@ -163,9 +163,9 @@ class ProjectService
->all();
// Snapshot ДО удаления — после delete() модель недоступна.
$tenantId = $project->tenant_id;
$tenantId = $project->tenant_id;
$projectId = $project->id;
$snapshot = $project->toArray();
$snapshot = $project->toArray();
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
@@ -241,7 +241,7 @@ class ProjectService
userId: auth()->id(),
entityType: 'project',
entityId: null,
event: 'project.bulk_' . $action,
event: 'project.bulk_'.$action,
payloadBefore: null,
payloadAfter: array_merge(['ids' => $ids], $result),
ip: request()->ip(),
@@ -9,7 +9,7 @@ return new class extends Migration
{
$sql = file_get_contents(base_path('../db/migrations/2026_05_22_001_tenant_operations_log.sql'));
if ($sql === false) {
throw new \RuntimeException('Migration SQL file not found.');
throw new RuntimeException('Migration SQL file not found.');
}
DB::unprepared($sql);
}
@@ -10,7 +10,7 @@ return new class extends Migration
public function up(): void
{
// Guard: only run if webhook_log exists (should always exist, but be safe)
if (! \Schema::hasTable('webhook_log')) {
if (! Schema::hasTable('webhook_log')) {
return;
}
@@ -23,7 +23,7 @@ return new class extends Migration
public function down(): void
{
if (! \Schema::hasTable('webhook_log')) {
if (! Schema::hasTable('webhook_log')) {
return;
}
@@ -24,12 +24,12 @@ function stubAdminUser(string $email = 'audit-stub@test.local'): int
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => $email,
'full_name' => 'Audit Test Stub',
'email' => $email,
'full_name' => 'Audit Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
}
@@ -46,7 +46,7 @@ it('setExportMode writes audit log row', function (): void {
$countBefore = DB::table('saas_admin_audit_log')->count();
$this->postJson('/api/admin/supplier-integration/export-mode', [
'mode' => 'online',
'mode' => 'online',
'admin_user_id' => $adminId,
])->assertOk();
@@ -67,24 +67,30 @@ it('setExportMode writes audit log row', function (): void {
it('manualQueueResolve writes audit log row', function (): void {
$adminId = stubAdminUser();
$admin = User::factory()->create();
$admin = User::factory()->create();
$this->actingAs($admin);
$tenant = Tenant::factory()->create();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$queueRow = SupplierManualSyncQueue::create([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'audit-test.ru'],
'failure_reason' => 'contract_break',
'status' => 'pending',
'failure_reason' => 'contract_break',
'status' => 'pending',
]);
$channelMock = new class implements SupplierProjectChannel {
public function createProject(SupplierProjectDto $dto): int { return 0; }
$channelMock = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [['id' => 77777, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'audit-test.ru']];
@@ -114,28 +120,30 @@ it('projectsDestroy writes audit log row', function (): void {
$adminId = stubAdminUser();
$this->actingAs(User::factory()->create());
$clientMock = new class extends SupplierPortalClient {
$clientMock = new class extends SupplierPortalClient
{
public function __construct() {}
public function deleteProject(int $externalId): void {}
};
app()->instance(SupplierPortalClient::class, $clientMock);
$sp = SupplierProject::query()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'audit-destroy.ru',
'subject_code' => 77,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'audit-destroy.ru',
'subject_code' => 77,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'supplier_external_id' => '12345',
]);
$countBefore = DB::table('saas_admin_audit_log')->count();
$this->postJson('/api/admin/supplier-integration/projects/delete', [
'ids' => [$sp->id],
'ids' => [$sp->id],
'admin_user_id' => $adminId,
])->assertOk()->assertJson(['deleted' => 1, 'failures' => []]);
@@ -29,21 +29,21 @@ it('full operational flow produces rows in all four audit tables', function ():
// ── Shared fixtures ──────────────────────────────────────────────────────
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = ' . $tenant->id);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
// Ensure a saas_admin_users row exists for saas_admin_audit_log FK.
// Must be is_active=true so incidents:watch-failures can resolve the FK too.
$adminId = DB::table('saas_admin_users')->where('email', 'opflow-stub@test.local')->value('id');
if ($adminId === null) {
$adminId = (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'opflow-stub@test.local',
'full_name' => 'OpFlow Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => true,
'sso_provider' => 'local',
'email' => 'opflow-stub@test.local',
'full_name' => 'OpFlow Test Stub',
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => true,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
}
@@ -51,11 +51,11 @@ it('full operational flow produces rows in all four audit tables', function ():
// ── Step 1: Create a project → project.created in tenant_operations_log ──
$this->actingAs($user)->postJson('/api/projects', [
'name' => 'Опфлоу тест',
'signal_type' => 'site',
'signal_identifier' => 'opflow-test.ru',
'name' => 'Опфлоу тест',
'signal_type' => 'site',
'signal_identifier' => 'opflow-test.ru',
'daily_limit_target' => 30,
'regions' => [],
'regions' => [],
'delivery_days_mask' => 127,
])->assertStatus(201);
@@ -105,8 +105,8 @@ it('full operational flow produces rows in all four audit tables', function ():
// ── Step 4: Change webhook URL → webhook_settings.updated in tenant_operations_log
$oldUrl = 'https://8.8.8.8/hook';
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'target_url' => $oldUrl,
]);
@@ -122,7 +122,7 @@ it('full operational flow produces rows in all four audit tables', function ():
->first();
expect($webhookSettingsRow)->not->toBeNull('webhook_settings.updated must be logged');
$wsAfter = json_decode($webhookSettingsRow->payload_after, true);
$wsAfter = json_decode($webhookSettingsRow->payload_after, true);
$wsBefore = json_decode($webhookSettingsRow->payload_before, true);
expect($wsAfter['target_url'] ?? null)->toBe($newUrl);
expect($wsBefore['target_url'] ?? null)->toBe($oldUrl);
@@ -143,7 +143,7 @@ it('full operational flow produces rows in all four audit tables', function ():
$this->actingAs(User::factory()->create())
->postJson('/api/admin/supplier-integration/export-mode', [
'mode' => 'online',
'mode' => 'online',
'admin_user_id' => $adminId,
])->assertOk();
@@ -166,10 +166,10 @@ it('full operational flow produces rows in all four audit tables', function ():
$webhookLogCountBefore = DB::table('webhook_log')->count();
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
'vid' => 999901,
'vid' => 999901,
'project' => 'B1_opflow-test.ru',
'phone' => '79991234567',
'time' => time(),
'phone' => '79991234567',
'time' => time(),
])->assertStatus(404);
expect(DB::table('webhook_log')->count())->toBe($webhookLogCountBefore + 1);
@@ -189,8 +189,8 @@ it('full operational flow produces rows in all four audit tables', function ():
$rows = [];
for ($i = 0; $i < 201; $i++) {
$rows[] = [
'failed_at' => $now,
'exception' => 'App\\Exceptions\\WebhookException: opflow-storm',
'failed_at' => $now,
'exception' => 'App\\Exceptions\\WebhookException: opflow-storm',
'raw_payload' => '{}',
'retry_count' => 0,
];
@@ -9,13 +9,13 @@ use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
// Helper: insert a failed_webhook_jobs row
function makeFailedWebhookJob(string $exception, ?\DateTimeInterface $at = null): void
function makeFailedWebhookJob(string $exception, ?DateTimeInterface $at = null): void
{
DB::table('failed_webhook_jobs')->insert([
'failed_at' => $at ?? now(),
'exception' => $exception,
'raw_payload' => '{}',
'retry_count' => 0,
'failed_at' => $at ?? now(),
'exception' => $exception,
'raw_payload' => '{}',
'retry_count' => 0,
]);
}
@@ -28,12 +28,12 @@ function ensureSystemAdmin(): int
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'system-cron@liderra.ru',
'full_name' => 'System Cron',
'email' => 'system-cron@liderra.ru',
'full_name' => 'System Cron',
'password_hash' => '$2y$12$placeholder',
'role' => 'dev_oncall',
'is_active' => true,
'created_at' => now(),
'role' => 'dev_oncall',
'is_active' => true,
'created_at' => now(),
]);
}
@@ -21,10 +21,10 @@ it('logs status=received when lead is accepted (202)', function () {
Bus::fake();
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900001,
'vid' => 900001,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
'phone' => '79991234567',
'time' => time(),
])->assertStatus(202);
$log = DB::table('webhook_log')
@@ -41,10 +41,10 @@ it('logs status=received when lead is accepted (202)', function () {
it('logs status=rejected_secret when secret is wrong (404)', function () {
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
'vid' => 900002,
'vid' => 900002,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
'phone' => '79991234567',
'time' => time(),
])->assertStatus(404);
$log = DB::table('webhook_log')
@@ -64,10 +64,10 @@ it('logs status=rejected_ip when IP is not in allowlist (404)', function () {
$this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900003,
'vid' => 900003,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
'phone' => '79991234567',
'time' => time(),
])->assertStatus(404);
$log = DB::table('webhook_log')
@@ -91,10 +91,10 @@ it('logs status=rate_limited when per-IP rate limit exceeded (429)', function ()
}
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 900004,
'vid' => 900004,
'project' => 'B1_log-test.ru',
'phone' => '79991234567',
'time' => time(),
'phone' => '79991234567',
'time' => time(),
])->assertStatus(429);
$log = DB::table('webhook_log')
@@ -5,27 +5,28 @@ declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
Queue::fake();
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
DB::statement('SET app.current_tenant_id = ' . $this->tenant->id);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
});
it('logs project.created when a project is stored', function () {
$this->actingAs($this->user)->postJson('/api/projects', [
'name' => 'Окна СПб',
'signal_type' => 'site',
'signal_identifier' => 'okna-spb.ru',
'name' => 'Окна СПб',
'signal_type' => 'site',
'signal_identifier' => 'okna-spb.ru',
'daily_limit_target' => 50,
'regions' => [],
'regions' => [],
'delivery_days_mask' => 127,
])->assertStatus(201);
@@ -40,7 +41,7 @@ it('logs project.created when a project is stored', function () {
it('logs project.updated with before/after diff when a project is patched', function () {
$project = Project::factory()->create([
'tenant_id' => $this->tenant->id,
'tenant_id' => $this->tenant->id,
'daily_limit_target' => 10,
]);
@@ -57,7 +58,7 @@ it('logs project.updated with before/after diff when a project is patched', func
expect($row)->not->toBeNull();
$before = json_decode($row->payload_before, true);
$after = json_decode($row->payload_after, true);
$after = json_decode($row->payload_after, true);
expect($before)->toHaveKey('daily_limit_target');
expect($after)->toHaveKey('daily_limit_target');
@@ -86,7 +87,7 @@ it('logs project.bulk_<action> when a bulk action is executed', function () {
$this->actingAs($this->user)->postJson('/api/projects/bulk', [
'action' => 'pause',
'ids' => [$p1->id, $p2->id],
'ids' => [$p1->id, $p2->id],
])->assertOk()->assertJsonPath('updated', 2);
$row = DB::table('tenant_operations_log')
@@ -1,4 +1,6 @@
<?php declare(strict_types=1);
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
@@ -11,7 +13,7 @@ it('api_key.regenerated logged with key_prefix only (NO plain key)', function ()
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET app.current_tenant_id = ' . $tenant->id);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$r = $this->postJson('/api/api-keys/regenerate');
$r->assertStatus(201);
@@ -1,4 +1,6 @@
<?php declare(strict_types=1);
<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
@@ -12,7 +14,7 @@ it('webhook_settings.updated logged with before/after target_url', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET app.current_tenant_id = ' . $tenant->id);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$oldUrl = 'https://8.8.8.8/hook';
OutboundWebhookSubscription::factory()->create([
@@ -48,7 +50,7 @@ it('webhook_settings.updated logged when subscription created for first time', f
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET app.current_tenant_id = ' . $tenant->id);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$newUrl = 'https://93.184.216.34/hook';
$r = $this->putJson('/api/tenants/me/webhook-settings', [
@@ -76,7 +78,7 @@ it('audit log does not contain webhook secret', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET app.current_tenant_id = ' . $tenant->id);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$r = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://93.184.216.34/hook',
@@ -1,8 +1,11 @@
<?php declare(strict_types=1);
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\OperationsLogger;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@@ -58,5 +61,5 @@ it('append-only: UPDATE blocked by audit_block_mutation', function () {
);
$id = (int) DB::table('tenant_operations_log')->latest('id')->value('id');
expect(fn () => DB::table('tenant_operations_log')->where('id', $id)->update(['event' => 'x']))
->toThrow(\Illuminate\Database\QueryException::class);
->toThrow(QueryException::class);
});
@@ -1,5 +1,7 @@
# P2 — Operational journaling (projects / API keys / webhook URL / admin-supplier / incidents auto)
> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p2-operational` (от P0+P1 base). 11 commits (Tasks 1-9 + gate). New schema v8.28/v8.29 (`tenant_operations_log` table + 2 indexes + 1 RLS + 2 hash-chain триггера; `webhook_log` ALTER +5 колонок). Все 4 ветки `tenant_operations_log` пишутся: project.created/updated/deleted/bulk_<action>, api_key.regenerated (key_prefix only — no plain key), webhook_settings.updated. Admin actions (export_mode_set / manual_queue_resolved / projects_destroyed) → `saas_admin_audit_log`. SupplierWebhook → `webhook_log` (received/rejected_secret/rejected_ip/rate_limited). Cron `incidents:watch-failures` каждые 10 мин → `incidents_log` на failure-spike (threshold 200/окно, дедуп 60 мин). Touched-area regression **67/67 passing** (275 assertions); Pint **clean**; Larastan **production code clean** (0 real findings, 29 Pest TestCall false-positives в новых тестах — environmental, same pattern as P0/P1). NB: `--parallel` full-suite не запущен — targeted sequential regression substitute.
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`).
**Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher).