Files
portal/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
T
Дмитрий 61f35d0227 fix(supplier): перенос из main — в офлайн-режиме не дёргать поставщика при создании
Перенос 7fa811b4 из gitea/main в ветку стройки, чтобы выкат отсюда не вернул баг на
боевой (класс ошибки 08.07 — «затёрло выкатом из устаревшей ветки»).

Суть: в batch-режиме портал слал поставщику «каркас» с limit=0 и без регионов. Кабинет
отбивает такой запрос ВСЕГДА — снято живьём с боевого 14.07:
{"status":"Error","message":"Введите limit!"}. Портал считал отказ поломкой, дёргал
запасной путь через браузер, тот тоже падал → проект уезжал в ручную очередь. Итог на
бою: 114 мусорных записей и 2 ложных high-инцидента «кабинет поставщика упал» (кабинет
при этом жив — отдаёт 140 проектов).

Лиды не терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob (18:00) с
посчитанными лимитами и регионами. Теперь handleBatch к поставщику при создании не ходит
(слать нечего), идемпотентная привязка существующих строк сохранена.

NB: то же самое для ОНЛАЙН-пути в этой ветке уже сделано («кабинет отклоняет limit=0» —
площадки с нулевой долей не создаются). Правки не пересекаются: там handleOnline, тут
handleBatch.

Тесты: 254/254 (Supplier + Plan5) в этой ветке; в main полный прогон 2453/2458.

NB: LEFTHOOK_EXCLUDE=larastan — статанализ в рабочей копии сломан устаревшим
_ide_helper_models.php (фантомы «Project::aggregateSyncStatus() не существует», хотя метод
есть в Project.php:181). По изменённым файлам статанализ прогнан в чистой песочнице: 0
ошибок. Bypass согласован с владельцем. Follow-up: перегенерировать стаб (--write-mixin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:26:03 +03:00

123 lines
4.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\SupplierProjectGrouping;
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().
*/
function dispatchJobSync(SyncSupplierProjectJob $job): void
{
$job->handle(app(SupplierProjectChannel::class));
}
/**
* Прод-инцидент 14.07.2026 (см. SyncSupplierProjectJobTest в tests/Feature/Supplier):
* batch-режим больше НЕ ходит к поставщику при создании — кабинет всё равно отбивает
* каркас с limit=0 («Введите limit!»), а строки создаёт вечерний прогон 18:00 МСК.
*
* Раньше этот файл проверял разбор проекта на площадки ЧЕРЕЗ вызовы createProject.
* Сам разбор (site/call → B1+B2+B3, sms+keyword → B2+B3, sms без keyword → B3) никуда
* не делся — проверяем его напрямую у SupplierProjectGrouping, без похода к поставщику.
*/
it('site project resolves to B1+B2+B3', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']);
});
it('call project resolves to B1+B2+B3', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']);
});
it('sms project with keyword resolves to B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B2', 'B3']);
});
it('sms project without keyword resolves to B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B3']);
});
it('batch create: supplier is never called and nothing lands in the manual queue', 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->shouldNotReceive('createProject');
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect(SupplierProject::where('unique_key', 'okna.ru')->count())->toBe(0);
expect(DB::table('supplier_manual_sync_queue')->where('project_id', $project->id)->count())->toBe(0);
});
it('idempotency: pre-existing supplier_project row is linked, channel still not called', 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',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldNotReceive('createProject');
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
// Существующая строка привязана...
expect($project->supplier_b2_project_id)->toBe($spB2->id);
// ...а недостающие B1/B3 остаются на вечерний прогон.
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b3_project_id)->toBeNull();
});