put('supplier:session', [ 'phpsessid' => 'sess', 'csrf' => 'csrf', 'refreshed_at' => now()->toIso8601String(), ], now()->addHours(6)); config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); config(['services.supplier.alert_email' => 'ops@liderra.test']); }); afterEach(function (): void { Cache::store('redis')->forget('supplier:session'); Carbon::setTestNow(); }); test('creates supplier_project at supplier when supplier_external_id is null', function (): void { $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'create-flow.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'create-flow.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake([ 'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200), ]); (new SyncSupplierProjectsJob)->handle(); $sp->refresh(); expect($sp->supplier_external_id)->toBe('555') ->and($sp->sync_status)->toBe('ok') ->and($sp->current_limit)->toBe(3); Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save')); }); test('updates when diff detected', function (): void { $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'update-flow.example.com', 'supplier_external_id' => '12345', 'current_limit' => 1, 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'update-flow.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 30, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake([ 'crm.bp-gr.ru/admin/rt-project-update' => Http::response([], 200), ]); (new SyncSupplierProjectsJob)->handle(); $sp->refresh(); expect($sp->current_limit)->toBe(10) ->and($sp->sync_status)->toBe('ok'); Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-update')); }); test('skips when no diff between current and computed allocation', function (): void { $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'no-diff.example.com', 'supplier_external_id' => '999', 'current_limit' => 9, 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok', ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'no-diff.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 27, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake(); (new SyncSupplierProjectsJob)->handle(); Http::assertNothingSent(); }); test('isolates failure: one bad supplier_project does not stop others', function (): void { $tenant = Tenant::factory()->create(); $bad = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); $good = SupplierProject::factory()->create([ 'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'good.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'bad.example.com', 'supplier_b1_project_id' => $bad->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'good.example.com', 'supplier_b2_project_id' => $good->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save') ->push('bad request', 422) ->push(['id' => 777], 200); (new SyncSupplierProjectsJob)->handle(); expect( SupplierSyncLog::on('pgsql_supplier') ->where('supplier_project_id', $bad->id) ->whereNotNull('error_message') ->exists() )->toBeTrue(); expect($good->fresh()->supplier_external_id)->toBe('777'); }); test('aborts after 50 consecutive transient failures and sends alert', function (): void { Mail::fake(); $tenant = Tenant::factory()->create(); for ($i = 1; $i <= 60; $i++) { $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => "host{$i}.example.com", 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => "host{$i}.example.com", 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); } Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); (new SyncSupplierProjectsJob)->handle(); Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { return $mail->alertType === 'mass_transient'; }); }); test('writes supplier_sync_log row for each successful action', function (): void { $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'audit-log.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'audit-log.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake([ 'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200), ]); (new SyncSupplierProjectsJob)->handle(); $log = SupplierSyncLog::on('pgsql_supplier') ->where('supplier_project_id', $sp->id) ->first(); expect($log)->not->toBeNull() ->and($log->action)->toBe('create') ->and($log->http_status)->toBe(200) ->and($log->error_message)->toBeNull(); }); test('respects time budget by stopping at 20:55 МСК', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow')); $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'time-budget.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'time-budget.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake(); (new SyncSupplierProjectsJob)->handle(); Http::assertNothingSent(); }); test('sticky auth error throws and sends critical alert email', function (): void { Mail::fake(); Bus::fake([RefreshSupplierSessionJob::class]); $tenant = Tenant::factory()->create(); $sp = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'auth-fail.example.com', 'supplier_external_id' => null, 'current_limit' => 0, 'current_workdays' => [], 'current_regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'auth-fail.example.com', 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255, 'region_mode' => 'include', ]); Http::fake([ 'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401), ]); expect(fn () => (new SyncSupplierProjectsJob)->handle()) ->toThrow(SupplierAuthException::class); Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { return $mail->alertType === 'sticky_auth'; }); });