in('Feature')). // DatabaseTransactions — per-test isolation. uses(DatabaseTransactions::class); /** * Хелпер: разрешает мок SupplierPortalClient из контейнера и вызывает Job.handle(). * Нельзя использовать (new Job)->handle() без аргументов — handle() требует DI-инъекцию * SupplierPortalClient; прямой вызов без аргументов обходит контейнер и мок не применяется. */ function dispatchJobSync(SyncSupplierProjectJob $job): void { $client = app(SupplierPortalClient::class); $job->handle($client); } it('site project: links 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(SupplierPortalClient::class, function ($mock) { $mock->shouldReceive('ensureSupplierProject')->times(3) ->andReturnUsing(fn (string $platform, string $signalType, string $key) => SupplierProject::factory()->create([ 'platform' => $platform, // uppercase: B1, B2, B3 'signal_type' => $signalType, 'unique_key' => $key, 'sync_status' => 'ok', ])->id ); }); 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(); }); it('call project: links B1+B2+B3 with phone signal_identifier', function () { $project = Project::factory()->create([ 'signal_type' => 'call', 'signal_identifier' => '79161234567', ]); $this->mock(SupplierPortalClient::class, function ($mock) { $mock->shouldReceive('ensureSupplierProject')->times(3) ->andReturn(SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'call', 'sync_status' => 'ok', ])->id); }); 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: links B2+B3 only (no B1)', function () { $project = Project::factory()->create([ 'signal_type' => 'sms', 'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека', ]); $this->mock(SupplierPortalClient::class, function ($mock) { $mock->shouldReceive('ensureSupplierProject')->times(2) ->andReturnUsing(fn (string $platform) => SupplierProject::factory()->create([ 'platform' => $platform, // B2 or B3 — both pass CHECK constraint 'signal_type' => 'sms', 'sync_status' => 'ok', ])->id ); }); 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: links B3 only', function () { $project = Project::factory()->create([ 'signal_type' => 'sms', 'sms_senders' => ['TINKOFF'], 'sms_keyword' => null, ]); $this->mock(SupplierPortalClient::class, function ($mock) { $mock->shouldReceive('ensureSupplierProject')->once() ->andReturn(SupplierProject::factory()->create([ 'platform' => 'B3', 'signal_type' => 'sms', 'sync_status' => 'ok', ])->id); }); 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('portal exception: re-throws for queue retry', function () { $project = Project::factory()->create([ 'signal_type' => 'site', 'signal_identifier' => 'x.ru', ]); $this->mock(SupplierPortalClient::class, function ($mock) { $mock->shouldReceive('ensureSupplierProject') ->andThrow(new RuntimeException('timeout')); }); expect(fn () => dispatchJobSync(new SyncSupplierProjectJob($project->id))) ->toThrow(RuntimeException::class); }); it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs written', function () { $project = Project::factory()->create([ 'signal_type' => 'site', 'signal_identifier' => 'x.ru', ]); // Pre-create a supplier_project row for B2 with sync_status='failed' — // the mock returns its ID to simulate a failed B2 sync. // NOTE: supplier_projects has NO last_error column (schema v8.19); // "failed" status alone is the observable signal. $spB2 = SupplierProject::factory()->create([ 'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'x.ru', 'sync_status' => 'failed', ]); $this->mock(SupplierPortalClient::class, function ($mock) use ($spB2) { $spB1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'sync_status' => 'ok'])->id; $spB3 = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'site', 'sync_status' => 'ok'])->id; $mock->shouldReceive('ensureSupplierProject')->andReturn($spB1, $spB2->id, $spB3); }); dispatchJobSync(new SyncSupplierProjectJob($project->id)); $project->refresh(); expect($project->supplier_b2_project_id)->not->toBeNull(); 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(); });