Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
T
Дмитрий 6e5460be5e feat(slepok): Task 2.9 — SyncSupplierProjectsJob reads from snapshot (race 18:02→18:05 closure)
After Stage 2 запуска, 18:05 МСК sync читает project_routing_snapshots за tomorrow
МСК, не live projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync):
клиент мог нажать «пауза» в эти 3 минуты, но мы всё равно докатываем зафиксированный
slepok поставщику (slepok-инвариант).

collectEligibleProjects() переписан с Project::on()->where('is_active', true)
на Project::on()->join('project_routing_snapshots AS snap', ...). Snapshot уже
отфильтрован по is_active/preflight_blocked/frozen_tenant; повторно проверяем
frozen-фильтр на случай freeze в эти 3 минуты. daily_limit_target /
delivery_days_mask / regions переопределяются значениями snapshot (slepok-семантика);
downstream syncGroup() работает без изменений.

Spec §4.2.4b. Closes race 18:02→18:05.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.9

Tests:
- tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php (4 new tests, PASS).
- tests/Feature/Supplier/SyncSupplierProjectsJobTest.php — 12 existing tests patched
  with insertSnapshotForTomorrow($project) helper (12/12 GREEN).
- tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php — 2 existing tests
  patched (2/2 GREEN).
- tests/Pest.php — global helper insertSnapshotForTomorrow().

Combined sync regression: 19/20 PASS + 1 skipped (pre-existing).

Patched via 2 parallel Sonnet subagents per Pravila §15.1; controller-verified
combined regression.
2026-05-28 06:59:09 +03:00

580 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use Carbon\Carbon;
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);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
Cache::store('redis')->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();
});
// ---------------------------------------------------------------------------
// Multi-region grouping (merged into single group)
// ---------------------------------------------------------------------------
/**
* Project regions=[82,83] site → 1 group (merged regions) → tag='РФ' →
* 1 multi-flag save → 3 supplier_projects (platforms B1/B2/B3)
* subject_code=null, current_regions=[82,83]; pivot — 3 links for the project.
*/
test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 supplier_projects + 3 pivot links', function (): void {
$tenant = Tenant::factory()->create();
/** @var Project $project */
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'persubject.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [82, 83],
]);
insertSnapshotForTomorrow($project, regions: '{82,83}');
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['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,
),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// 3 supplier_projects (not 6): all regions merged into one group
$sps = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'persubject.example.com')
->where('signal_type', 'site')
->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
// subject_code=null (no per-subject split)
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
// regions merged: [82, 83] — sorted ascending, stored on each SP
expect($sps->firstWhere('platform', 'B1')->current_regions)->toBe([82, 83]);
// pivot: 3 links (not 6)
$pivotCount = DB::table('project_supplier_links')
->where('project_id', $project->id)
->count();
expect($pivotCount)->toBe(3);
});
// ---------------------------------------------------------------------------
// 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,
'signal_type' => 'site',
'signal_identifier' => 'rf-pool.example.com',
'daily_limit_target' => 6,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
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([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
$tenant = Tenant::factory()->create();
$project1 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project1);
$project2 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 20,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project2);
// 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 (the GROUP order), then split
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
$sps = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'order-test.example.com')
->get();
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
expect($sps)->toHaveCount(3);
expect($sps->sum('current_limit'))->toBe(20);
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
});
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79135161263',
'daily_limit_target' => 18,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
]], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
expect($sps)->toHaveCount(3);
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
});
// ---------------------------------------------------------------------------
// SMS platforms
// ---------------------------------------------------------------------------
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79001234567'],
'sms_keyword' => 'KVARTIRA',
'daily_limit_target' => 5,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79009876543'],
'sms_keyword' => null,
'daily_limit_target' => 5,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'idempotent.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
// 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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'time-budget.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'auth-fail.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => "host{$i}.abort.com",
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
}
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 = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => 'audit-log.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
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();
});
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79993334455',
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project);
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::on('pgsql_supplier')->forceCreate([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79993334455',
'subject_code' => null,
'supplier_external_id' => 'GONE'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
return Http::response(['projects' => [
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
]], 200);
},
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});