put( 'supplier:session', ['phpsessid' => 'test', 'csrf' => 'test'], now()->addHour(), ); } beforeEach(function (): void { Mail::fake(); Bus::fake([RouteSupplierLeadJob::class]); app()->bind(RefreshSupplierSessionJob::class, fn () => new class { public function handle(): void { putSupplierSession(); } }); Cache::store('redis')->forget('supplier:csv_reconcile'); putSupplierSession(); config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); config(['services.supplier.alert_email' => 'ops@liderra.ru']); }); afterEach(function (): void { Cache::store('redis')->forget('supplier:csv_reconcile'); }); /** * 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone. * * @param array $rows */ function csvBody(array $rows): string { $out = "Name;Tag;Phone\n"; foreach ($rows as $r) { $out .= "{$r['project']};tag;{$r['phone']}\n"; } return $out; } /** * Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19): * POST /admin/report/save-report → "OK" * GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match) * GET /admin/report/getfile?id=N → raw CSV * * Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body * и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся. */ function fakeReportFlow(string $csv): void { $captured = ['from' => '', 'to' => '']; Http::fake([ 'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) { $body = $r->data(); $captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? ''); $captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? ''); return Http::response('OK', 200); }, 'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) { $title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']); return Http::response([ ['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'], ], 200); }, 'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200), ]); } function runCsvReconcile(): void { app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ); } it('no missing leads — status=ok, no recovery, no alert', function (): void { for ($i = 0; $i < 10; $i++) { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => "7999000000{$i}", 'vid' => 800000 + $i, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); } $rows = []; for ($i = 0; $i < 10; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"]; } fakeReportFlow(csvBody($rows)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->total_csv_rows)->toBe(10); expect((int) $log->matched_count)->toBe(10); expect((int) $log->recovered_count)->toBe(0); Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots Bus::assertNothingDispatched(); }); it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void { for ($i = 0; $i < 9; $i++) { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => "7999111000{$i}", 'vid' => 810000 + $i, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); } $rows = []; for ($i = 0; $i < 10; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"]; } fakeReportFlow(csvBody($rows)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('drift_alert'); expect((float) $log->drift_ratio)->toBeGreaterThan(0.05); expect((int) $log->recovered_count)->toBe(1); $recovered = SupplierLead::where('source', 'csv_recovery')->first(); expect($recovered)->not->toBeNull(); expect($recovered->vid)->toBeNull(); expect($recovered->recovered_from_csv_at)->not->toBeNull(); Mail::assertSent(CsvDriftAlertMail::class, 1); Bus::assertDispatched(RouteSupplierLeadJob::class, 1); }); it('1 missing of 100 (drift 1%) — recovery without alert', function (): void { for ($i = 0; $i < 99; $i++) { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), 'vid' => 820000 + $i, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); } $rows = []; for ($i = 0; $i < 100; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; } fakeReportFlow(csvBody($rows)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->recovered_count)->toBe(1); Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots }); it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => '79995550000', 'vid' => 830000, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); fakeReportFlow(csvBody([ ['project' => 'B1_a.com', 'phone' => '79995550000'], ['project' => 'B2_b.com', 'phone' => '79995550000'], ])); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->matched_count)->toBe(1); expect((int) $log->recovered_count)->toBe(1); }); it('empty CSV — status=ok, drift=0', function (): void { fakeReportFlow("Name;Tag;Phone\n"); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->total_csv_rows)->toBe(0); }); it('overlap lock held — job skips, no log row', function (): void { $countBefore = DB::table('supplier_csv_reconcile_log')->count(); $lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600); $lock->get(); try { runCsvReconcile(); } finally { $lock->release(); } expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore); }); it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void { Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]); expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('failed'); expect($log->error_message)->toContain('500'); }); it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void { // 100 нормальных webhook-лидов. for ($i = 0; $i < 100; $i++) { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), 'vid' => 840000 + $i, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); } // CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null). // Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты // (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex. $rows = []; for ($i = 0; $i < 100; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; } $junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&']; foreach ($junkProjects as $j => $junk) { $rows[] = ['project' => $junk, 'phone' => '7999500000'.$j]; } fakeReportFlow(csvBody($rows)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->total_csv_rows)->toBe(110); expect((int) $log->matched_count)->toBe(100); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->unparseable_count)->toBe(10); // Реального missing'а нет — только junk; drift должен быть 0, не 10/110. expect((float) $log->drift_ratio)->toBe(0.0); expect($log->status)->toBe('ok'); Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots }); it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void { for ($i = 0; $i < 95; $i++) { SupplierLead::create([ 'supplier_project_id' => null, 'platform' => 'B1', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), 'vid' => 850000 + $i, 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], 'received_at' => now()->subHour(), 'source' => 'webhook', ]); } $rows = []; for ($i = 0; $i < 95; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; } // Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus). $junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!']; foreach ($junkProjects as $j => $junk) { $rows[] = ['project' => $junk, 'phone' => '7999600000'.$j]; } for ($k = 0; $k < 3; $k++) { $rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k]; } fakeReportFlow(csvBody($rows)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect((int) $log->total_csv_rows)->toBe(103); expect((int) $log->matched_count)->toBe(95); expect((int) $log->recovered_count)->toBe(3); expect((int) $log->unparseable_count)->toBe(5); // real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok. expect((float) $log->drift_ratio)->toBeLessThan(0.05); expect((float) $log->drift_ratio)->toBeGreaterThan(0.0); expect($log->status)->toBe('ok'); }); // --------------------------------------------------------------------------- // Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass. // After existing webhook-loss drift detection, CsvReconcileJob runs a second // pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups // where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail. // This is orthogonal to webhook-loss drift (R-05.1) — same lead can be: // - delivered & webhook OK (no alerts) // - delivered & webhook miss (R-05.1 CsvDriftAlertMail) // - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task) // --------------------------------------------------------------------------- function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void { $tenant = \App\Models\Tenant::find($tenantId) ?? \App\Models\Tenant::factory()->create(); $project = \App\Models\Project::factory() ->for($tenant) ->asCallSignal('7977'.\Illuminate\Support\Str::random(7)) ->create([ 'is_active' => true, 'daily_limit_target' => max($expected, 1), ]); \Illuminate\Support\Facades\DB::connection('pgsql_supplier') ->table('project_routing_snapshots') ->insert([ 'snapshot_date' => $date, 'project_id' => $project->id, 'tenant_id' => $tenant->id, 'daily_limit' => max($expected, 1), 'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call', 'signal_identifier' => $project->signal_identifier, 'sms_senders' => null, 'sms_keyword' => null, 'expected_volume' => $expected, 'delivered_count' => $delivered, 'created_at' => now(), ]); } it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void { $tenant = \App\Models\Tenant::factory()->create(); // Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold). $yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 2); // Empty CSV — primary drift pass is trivially OK; we exercise only the second pass. fakeReportFlow(csvBody([])); runCsvReconcile(); Mail::assertSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { return $mail->tenantId === $tenant->id && $mail->expected === 10 && $mail->delivered === 2 && $mail->shortfallRatio >= 0.79 && $mail->shortfallRatio <= 0.81; }); }); it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void { $tenant = \App\Models\Tenant::factory()->create(); // Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold). $yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 9); fakeReportFlow(csvBody([])); runCsvReconcile(); // Scoped assertion: prior-run leaked snapshots may fire mails for other tenants; // this test only owns one tenant, so assert no mail was sent for IT. Mail::assertNotSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { return $mail->tenantId === $tenant->id; }); });