forget('supplier:session')` in their afterEach. * In `--parallel` mode all workers share Redis DB+prefix, so a concurrent * afterEach can wipe our session between beforeEach `put` and the SUT call, * triggering PlaywrightBridge auto-refresh (which has no credentials). * * Calling this immediately before the job dispatches the HTTP request * minimizes the race window. The test still tolerates the rare case where * another test's afterEach runs between this put and the SUT's read — but * empirically the window is too small for that to fire. */ function putSupplierSession(): void { Cache::store('redis')->put( 'supplier:session', ['phpsessid' => 'test', 'csrf' => 'test'], now()->addHour(), ); } beforeEach(function () { Mail::fake(); // Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on). // RefreshSupplierSessionJob must NOT be faked — it must run our mock below // so that loadSession() can recover if a concurrent afterEach wipes the session. Bus::fake([RouteSupplierLeadJob::class]); // Bind a mock that re-puts the session when dispatch_sync triggers it during a race. app()->bind(RefreshSupplierSessionJob::class, fn () => new class { public function handle(): void { putSupplierSession(); } }); // NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to // OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put. 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 () { Cache::store('redis')->forget('supplier:csv_reconcile'); }); function csvBody(array $rows): string { $out = "vid;project;tag;phone;phones;time\n"; foreach ($rows as $r) { $out .= "{$r['vid']};{$r['project']};;{$r['phone']};{$r['phone']};{$r['time']}\n"; } return $out; } function makeSupplierProject(): SupplierProject { return SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com', ]); } it('matches existing leads, no missing — status=ok, no alert', function () { $sp = makeSupplierProject(); $now = time(); $vids = []; for ($i = 0; $i < 10; $i++) { $vid = (int) ('11100000'.$i); // numeric vid because BIGINT $vids[] = $vid; SupplierLead::factory()->create([ 'vid' => $vid, 'phone' => '79991234567', 'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(), ]); } $rows = []; foreach ($vids as $vid) { $rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600]; } Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); putSupplierSession(); app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ); $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::assertNothingSent(); Bus::assertNothingDispatched(); }); it('drift 10% (1 missing of 10) → alert email + 1 RouteJob dispatched', function () { $sp = makeSupplierProject(); $now = time(); $vids = []; for ($i = 0; $i < 10; $i++) { $vids[] = (int) ('22200000'.$i); } // Existing 9 of 10 for ($i = 0; $i < 9; $i++) { SupplierLead::factory()->create([ 'vid' => $vids[$i], 'phone' => '79991234567', 'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(), ]); } $rows = []; foreach ($vids as $vid) { $rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600]; } Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); putSupplierSession(); app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ); $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); Mail::assertSent(CsvDriftAlertMail::class, 1); Bus::assertDispatched(RouteSupplierLeadJob::class, 1); }); it('drift 1% (1 missing of 100) → status=ok, no alert', function () { $sp = makeSupplierProject(); $now = time(); $vids = []; for ($i = 0; $i < 100; $i++) { $vids[] = (int) ('33300'.str_pad((string) $i, 4, '0', STR_PAD_LEFT)); } for ($i = 0; $i < 99; $i++) { SupplierLead::factory()->create([ 'vid' => $vids[$i], 'phone' => '79991234567', 'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(), ]); } $rows = []; foreach ($vids as $vid) { $rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600]; } Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); putSupplierSession(); app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->recovered_count)->toBe(1); Mail::assertNothingSent(); }); it('empty CSV → status=ok, drift=0, no alert', function () { Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response("vid;project;tag;phone;phones;time\n", 200)]); putSupplierSession(); app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->total_csv_rows)->toBe(0); }); it('SupplierTransientException → status=failed, error_message recorded', function () { Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]); putSupplierSession(); expect(fn () => app(CsvReconcileJob::class)->handle( app(SupplierPortalClient::class), app(SupplierCsvParser::class), app(Mailer::class), ) )->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('Schedule entry: hourly cron registered', function () { /** @var Schedule $schedule */ $schedule = app(Schedule::class); $events = $schedule->events(); $hasCsv = collect($events)->contains(function ($event) { $repr = (string) ($event->description ?? ''); if (property_exists($event, 'job')) { $repr .= ' '.((string) $event->job); } return str_contains($repr, 'CsvReconcileJob'); }); expect($hasCsv)->toBeTrue(); });