Files
portal/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
T
Дмитрий b29bfe2ac6 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:49:59 +03:00

154 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
// DatabaseTransactions — per-test isolation.
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
* Mock SupplierProjectChannel НЕ instanceof FailoverProjectChannel → job идёт
* по ветке createProject() (без эскалации) — это и тестируем здесь.
* Failover-эскалация покрыта FailoverProjectChannelTest.
*/
function dispatchJobSync(SyncSupplierProjectJob $job): void
{
$job->handle(app(SupplierProjectChannel::class));
}
it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(700001, 700002, 700003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// FK ведёт на local supplier_projects.id, не на portal external_id.
expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('700001');
});
it('call project: creates B1+B2+B3 with phone signal_identifier', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(800001, 800002, 800003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b2_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b3_project_id)->not->toBeNull();
});
it('sms project with keyword: creates B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(900001, 900002);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('sms project without keyword: creates B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->once()
->andReturn(910001);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('channel exception: re-throws for queue retry', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')
->andThrow(new RuntimeException('timeout'));
});
expect(fn () => dispatchJobSync(new SyncSupplierProjectJob($project->id)))
->toThrow(RuntimeException::class);
});
it('idempotency: pre-existing supplier_project row is reused, channel not called for it', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
// B2 уже существует локально (например, от прошлого частичного запуска).
$spB2 = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'x.ru',
'sync_status' => 'failed',
]);
// Channel дёргается только для B1 и B3 — B2 берётся из существующей строки.
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(700001, 700003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b2_project_id)->toBe($spB2->id);
expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed');
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});