705f35623c
В SyncSupplierProjectJob::handleOnline после insertOrIgnore основных линков добавлен блок: если signal_type=site и identifier — субдомен, ищем sp с unique_key=root и insertOrIgnore линк к каждому. +2 feature-теста: subdomain→4 links / root→3 links (no recursion to TLD). Baseline phpstan-baseline.neon: бамп mock() count 6→8 (Pest TestCall known false-positive). Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
8.8 KiB
PHP
235 lines
8.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 App\Services\Supplier\SupplierPortalClient;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
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();
|
|
});
|
|
|
|
// Spec 2026-05-22: автолинковка субдомен-проекта к корневому supplier_project.
|
|
// Тесты идут по handleOnline-ветке (project_supplier_links заполняется только там).
|
|
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
|
|
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
|
['key' => 'supplier_export_mode'],
|
|
['key' => 'supplier_export_mode', 'value' => 'online'],
|
|
);
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
|
]);
|
|
|
|
$rootSp = SupplierProject::create([
|
|
'platform' => 'B2',
|
|
'signal_type' => 'site',
|
|
'unique_key' => 'carmoney.ru',
|
|
'supplier_external_id' => 'ext-root-b2',
|
|
'current_limit' => 100,
|
|
'sync_status' => 'ok',
|
|
]);
|
|
|
|
// handleOnline path использует SupplierPortalClient::saveProjectMultiFlag, не channel.
|
|
$this->mock(SupplierPortalClient::class, function ($mock) {
|
|
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
|
'B1' => 700101, 'B2' => 700102, 'B3' => 700103,
|
|
]);
|
|
});
|
|
|
|
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
|
|
|
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
|
->where('project_id', $project->id)->count();
|
|
expect($ownLinks)->toBe(4);
|
|
|
|
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
|
->where('project_id', $project->id)
|
|
->where('supplier_project_id', $rootSp->id)
|
|
->exists();
|
|
expect($rootLink)->toBeTrue();
|
|
});
|
|
|
|
it('site root-level project: does NOT create additional links (no recursion to TLD)', function () {
|
|
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
|
['key' => 'supplier_export_mode'],
|
|
['key' => 'supplier_export_mode', 'value' => 'online'],
|
|
);
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'signal_type' => 'site',
|
|
'signal_identifier' => 'carmoney.ru',
|
|
]);
|
|
|
|
SupplierProject::create([
|
|
'platform' => 'B2',
|
|
'signal_type' => 'site',
|
|
'unique_key' => 'ru',
|
|
'supplier_external_id' => 'ext-ru-b2',
|
|
'current_limit' => 100,
|
|
'sync_status' => 'ok',
|
|
]);
|
|
|
|
$this->mock(SupplierPortalClient::class, function ($mock) {
|
|
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
|
'B1' => 700201, 'B2' => 700202, 'B3' => 700203,
|
|
]);
|
|
});
|
|
|
|
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
|
|
|
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
|
->where('project_id', $project->id)->count();
|
|
expect($linksCount)->toBe(3);
|
|
});
|