08605cf640
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>
255 lines
8.4 KiB
PHP
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();
|
|
});
|