Files
portal/app/tests/Feature/Supplier/CsvReconcileJobTest.php
T
Дмитрий 08605cf640 fix(tests): Bus::fake partial + session mock — close quirk #72
CsvReconcileJobTest used Bus::fake() (all jobs), silencing dispatch_sync of
RefreshSupplierSessionJob when a parallel afterEach wiped supplier:session.
Now: Bus::fake([RouteSupplierLeadJob::class]) + anonymous mock that re-puts
the session in handle(), making race-window recovery deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:35:06 +03:00

255 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Hard re-puts `supplier:session` immediately before the SUT reads it.
*
* Parallel-test race fix: other Supplier tests (Sync*, Cleanup*) call
* `Cache::store('redis')->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();
});