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(); });