85f8e9e7a0
- SyncSupplierProjectJob: replace stub with full implementation (tries=3, backoff=[15,60,300]s; resolvePlatforms uppercase B1/B2/B3; buildUniqueKey site/call→signal_identifier, sms B2→sender+keyword, B3→sender; column name via strtolower($platform) to match schema snake_case) - SupplierPortalClient: drop final modifier (Mockery testability); add ensureSupplierProject() idempotent lookup-or-create wrapper - Tests: 6 passing (site/call/sms-with-kw/sms-no-kw/exception/partial-failure); DI fix via dispatchJobSync() helper resolving mock from container; uppercase platform fixtures matching CHECK constraint B1/B2/B3; last_error column absent from schema — partial-failure test uses sync_status only - phpstan-baseline.neon: add $this->mock() Pest TestCase inference gaps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
6.5 KiB
PHP
171 lines
6.5 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\SupplierPortalClient;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
// TestCase auto-bound via tests/Pest.php (->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();
|
|
});
|