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(); }); // --------------------------------------------------------------------------- // Per-subject grouping // --------------------------------------------------------------------------- /** * Project regions=[82,83] site → 2 groups (Москва, СПб) → * 2 multi-flag saves → 6 supplier_projects (2 subjects × 3 platforms B1/B2/B3) * with correct subject_code/tag; pivot — 6 links for the project. */ test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links', function (): void { $tenant = Tenant::factory()->create(); /** @var Project $project */ $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'persubject.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, // all days 'regions' => [82, 83], ]); // saveProjectMultiFlag calls rt-project-save once per subject, then listProjects to get ids Http::fake([ // first save (subject 82 = Москва) 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::sequence() ->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200) // second save (subject 83 = Санкт-Петербург) ->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200), // listProjects called after each save — return 3 rows per group 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::sequence() // After first save (Москва tag) ->push(['projects' => [ ['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ]], 200) // After second save (СПб tag) ->push(['projects' => [ ['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '2001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '2002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '2003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], ]], 200), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // 6 supplier_projects created: 2 subjects × 3 platforms $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'persubject.example.com') ->where('signal_type', 'site') ->get(); expect($sps)->toHaveCount(6); // subject_code 82 → 3 rows (B1/B2/B3) $m = $sps->where('subject_code', 82); expect($m)->toHaveCount(3); expect($m->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']); // subject_code 83 → 3 rows $spb = $sps->where('subject_code', 83); expect($spb)->toHaveCount(3); // pivot: 6 links for this project $pivotCount = DB::table('project_supplier_links') ->where('project_id', $project->id) ->count(); expect($pivotCount)->toBe(6); }); // --------------------------------------------------------------------------- // All-RF pool // --------------------------------------------------------------------------- test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void { $tenant = Tenant::factory()->create(); /** @var Project $project */ $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'rf-pool.example.com', 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'rf-pool.example.com') ->where('signal_type', 'site') ->get(); expect($sps)->toHaveCount(3); expect($sps->pluck('subject_code')->unique()->all())->toContain(null); expect($sps->pluck('current_regions')->first())->toBe([]); // pivot $pivotCount = DB::table('project_supplier_links') ->where('project_id', $project->id) ->count(); expect($pivotCount)->toBe(3); }); // --------------------------------------------------------------------------- // Order: 2 projects on one (source × subject) → computeOrder // --------------------------------------------------------------------------- test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void { $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 10, 'delivery_days_mask' => 127, 'regions' => [], ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 20, 'delivery_days_mask' => 127, 'regions' => [], ]); // saveProjectMultiFlag called once (both projects share same group) Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 $sp = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'order-test.example.com') ->where('platform', 'B1') ->first(); expect($sp)->not->toBeNull(); expect($sp->current_limit)->toBe(20); // Only one save call (single group) — not 2 Http::assertSentCount(2); // 1 save + 1 listProjects }); // --------------------------------------------------------------------------- // SMS platforms // --------------------------------------------------------------------------- test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void { $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79001234567'], 'sms_keyword' => 'KVARTIRA', 'daily_limit_target' => 5, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], ['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('signal_type', 'sms') ->get(); // sms+keyword → B2+B3 only expect($sps)->toHaveCount(2); expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']); expect($sps->where('platform', 'B1')->count())->toBe(0); }); test('sms without keyword → platform B3 only (1 supplier_project)', function (): void { $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79009876543'], 'sms_keyword' => null, 'daily_limit_target' => 5, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('signal_type', 'sms') ->get(); expect($sps)->toHaveCount(1); expect($sps->first()->platform)->toBe('B3'); }); // --------------------------------------------------------------------------- // Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot) // --------------------------------------------------------------------------- test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void { $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'idempotent.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); // First run: create Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); expect(SupplierProject::on('pgsql_supplier') ->where('unique_key', 'idempotent.example.com') ->count())->toBe(3); // Second run: no changes → updateProject calls (rt-project-save with id != 0) Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // Still 3 (no duplicates) expect(SupplierProject::on('pgsql_supplier') ->where('unique_key', 'idempotent.example.com') ->count())->toBe(3); // updateProject sends id != 0 Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && (int) ($r['id'] ?? 0) !== 0); }); // --------------------------------------------------------------------------- // Orthogonal: time budget, auth, abort-50, sync_log // --------------------------------------------------------------------------- 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(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'time-budget.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake(); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); Http::assertNothingSent(); }); test('sticky auth error throws and sends critical alert email', function (): void { Mail::fake(); Bus::fake([RefreshSupplierSessionJob::class]); $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'auth-fail.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401), ]); expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class))) ->toThrow(SupplierAuthException::class); Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { return $mail->alertType === 'sticky_auth'; }); }); test('aborts after 50 consecutive transient failures and sends alert', function (): void { Mail::fake(); $tenant = Tenant::factory()->create(); for ($i = 1; $i <= 60; $i++) { Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => "host{$i}.abort.com", 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); } Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); 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(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'audit-log.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // 3 supplier_projects created → 3 log rows (one per platform) $sp = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'audit-log.example.com') ->where('platform', 'B1') ->first(); expect($sp)->not->toBeNull(); $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(); });