Files
portal/app/tests/Feature/Supplier/Channel/AjaxProjectChannelTest.php
T
Дмитрий 9b4bff48f0 fix(supplier): normalize rt-projects-load — dedup contract (code-review C1)
Финальное code-review вскрыло CRITICAL: dedup-сверка findOnPortal и
manualQueueResolve матчат строки портала по {platform, signal_type,
unique_key}, но listProjects отдавал сырое тело rt-projects-load.

Сырая форма (verified из recon-снапшота 2026-05-19):
- ответ — конверт {projects:[443 строки], tags, users, ...}, НЕ голый массив
  → listProjects возвращал весь dict, findOnPortal итерировал по ключам
  конверта (projects/tags/...) вместо строк проектов;
- строка проекта: {id, name:"B<n>_<key>", type:"hosts|calls|sms", content}
  — без platform/signal_type/unique_key.

Фикс:
- SupplierPortalClient::listProjects — извлекает body['projects'].
- AjaxProjectChannel::listProjects — нормализует сырые строки в контракт
  SupplierProjectChannel: platform <- префикс name "B<n>_", signal_type <-
  type (hosts->site/calls->call/sms->sms), unique_key <- content. Сырые
  поля сохранены. findOnPortal + manualQueueResolve матчат корректно.
- AjaxProjectChannelTest — тест нормализации против фактической формы
  портала (не идеального мока); SupplierPortalClientRtProjectTest —
  listProjects против конверта {projects}.

Также (code-review I1): findOnPortal catch — Log::warning проглоченного
исключения, иначе провал дедупа невидим (молчаливый дубль rt-проекта).

Code-review I2 (partial-unique индекс supplier_manual_sync_queue от
дубль-эскалаций при job-retry) и I3 (lockForUpdate в manualQueueResolve) —
follow-up до прод-релиза (эпик гейтится Б-1, не в проде).

Регрессия Pest 973/970/0 / 3 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:09:48 +03:00

108 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/*
* AjaxProjectChannel (Tier 1) — тонкий адаптер над SupplierPortalClient.
*
* Контракт rt-project-* верифицирован Task 1 (см. SupplierPortalClientRtProjectTest);
* здесь проверяем только что адаптер прозрачно делегирует на правильный endpoint.
*/
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test', 'csrf' => 'test',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
it('AjaxProjectChannel implements SupplierProjectChannel', function (): void {
expect(app(AjaxProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
});
it('createProject delegates to SupplierPortalClient::saveProject and returns external id', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'foo.com',
limit: 5,
workdays: [1, 2, 3],
regions: [],
regionsReverse: false,
status: 'active',
);
$id = app(AjaxProjectChannel::class)->createProject($dto);
expect($id)->toBe(700777);
});
it('updateProject delegates to SupplierPortalClient::updateProject with id:N', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'site',
uniqueKey: 'foo.com',
limit: 10,
workdays: [1],
regions: [],
regionsReverse: false,
status: 'active',
);
app(AjaxProjectChannel::class)->updateProject(700777, $dto);
Http::assertSent(fn ($r) => $r['id'] === 700777);
});
it('listProjects normalizes raw rt-rows to channel contract (platform/signal_type/unique_key)', function (): void {
// Сырая форма портала (verified 2026-05-19): конверт {projects:[...]},
// строка {id, name:"B<n>_<key>", type, content}. Адаптер маппит в контракт.
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
'projects' => [
['id' => '700001', 'name' => 'B1_okna.ru', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '700002', 'name' => 'B3_79991112233', 'type' => 'calls', 'content' => '79991112233'],
['id' => '700003', 'name' => 'noPrefix', 'type' => 'sms', 'content' => 'KEYWORD'],
],
], 200),
]);
$list = app(AjaxProjectChannel::class)->listProjects();
expect($list)->toHaveCount(3);
expect($list[0]['platform'])->toBe('B1');
expect($list[0]['signal_type'])->toBe('site');
expect($list[0]['unique_key'])->toBe('okna.ru');
expect($list[0]['id'])->toBe('700001'); // сырое поле сохранено
expect($list[1]['platform'])->toBe('B3');
expect($list[1]['signal_type'])->toBe('call');
expect($list[1]['unique_key'])->toBe('79991112233');
// name без B<n>_ префикса → platform null (контракт не ломается)
expect($list[2]['platform'])->toBeNull();
expect($list[2]['signal_type'])->toBe('sms');
expect($list[2]['unique_key'])->toBe('KEYWORD');
});