Files
portal/app/tests/Pest.php
T
Дмитрий dee2ebbcf8 feat(автоподбор): второй справочник канала А — Яндекс.Карты через локальный Playwright + слияние
Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 04:53:58 +03:00

238 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Models\Project;
use App\Models\SupplierProject;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesAdminPdo;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind different classes or traits.
|
*/
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
// admin-db middleware swaps default→pgsql_admin; share PDO для cross-connection
// visibility в admin-тестах (любой /api/admin/* эндпоинт). Глобально по Feature.
uses(SharesAdminPdo::class)->in('Feature');
pest()->extend(TestCase::class)->in('Browser');
// Unit/Autopodbor: часть тестов использует app() и requires Laravel-контейнер
// (в т.ч. FakeCompetitorAgentTest — резолвит CompetitorAgent через провайдер).
pest()->extend(TestCase::class)->in('Unit/Autopodbor');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}
/**
* Читает фикстуру автоподбора из tests/fixtures/autopodbor/ (реальные карточки 2ГИС/Яндекс).
*/
function autopodborFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/autopodbor/{$name}"));
}
/**
* Подставной загрузчик страниц для офлайн-тестов резолвера: по подстроке URL отдаёт
* заранее заданный HTML (иначе '' — как настоящий PageFetcher при неудаче).
*
* @param array<string,string> $byNeedle подстрока URL → HTML
*/
function stubPages(array $byNeedle): PageFetcher
{
return new class($byNeedle) implements PageFetcher
{
/** @param array<string,string> $map */
public function __construct(private array $map) {}
public function html(string $url): string
{
foreach ($this->map as $needle => $html) {
if (str_contains($url, $needle)) {
return $html;
}
}
return '';
}
};
}
/**
* Разметка списка категории 2ГИС для тестов канала А: фирмы [[id,name,site?],...].
* Общий хелпер (используют CategoryScraperTest и LiveFindCompetitorsTest).
*
* @param array<int, array{0:int|string,1:string,2?:?string}> $firms
*/
function listingHtml(array $firms): string
{
$h = '';
foreach ($firms as $f) {
$id = $f[0];
$name = $f[1];
$site = $f[2] ?? null;
$h .= '<a href="/krasnoyarsk/firm/'.$id.'?stat=X" class="_1rehek"><span class="_lvwrwt"><span>'.$name.'</span></span></a>';
if ($site !== null) {
$h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}';
}
}
return $h;
}
/**
* Link a Лидерра-project to a supplier_project via the M:N pivot
* (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot
* only; legacy supplier_b{1,2,3}_project_id FK is ignored for routing.
*
* Single source — replaces previous duplicated declarations in
* LeadRouterTest.php / RouteSupplierLeadJobTest.php (Plan 2 cleanup).
* pivot created_at has DEFAULT NOW(); supplier->subject_code may be null.
*/
function linkProjectToSupplier(Project $project, SupplierProject $supplier): void
{
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => $supplier->subject_code,
]);
}
/**
* Pest helper для slepok-routing тестов (Task 2.5).
*
* Создаёт строку в `project_routing_snapshots` за активную дату слепка,
* отражающую "идеальное" состояние live-проекта. Используется тестами,
* которым нужен только факт «маршрутизация возможна», а не сам snapshot
* mechanism.
*
* Активная дата по умолчанию — сегодняшняя МСК (до 21:00 МСК). Передайте
* `$date` явно, если тест использует `Carbon::setTestNow` с другой датой.
*
* NB: signal_type/signal_identifier берутся ЯВНО из аргументов, а не из
* `$project->signal_type` — на Windows-native PG факториальный override
* этого поля не персистится (см. memory project_slepok_protection.md).
*/
/**
* Pest helper для SyncSupplierProjectsJob тестов (Task 2.9).
*
* Вставляет snapshot в `project_routing_snapshots` за активную дату слепка
* для tomorrow МСК (cron 18:02 МСК ежедневно создаёт slepok на завтра).
*
* После Task 2.9 sync-job читает snapshot, не live `projects.is_active` —
* без снимка проект не попадает в группировку для подачи поставщику.
*/
/**
* Слепок зеркалит источник проекта (signal_type / signal_identifier / sms_senders /
* sms_keyword), как прод-джоб SnapshotProjectRoutingJob. Офлайн-батч (Task 2.6) читает
* источник ИЗ слепка, поэтому фикстуры обязаны его нести — иначе группировка пустеет.
*
* Дефолты `false` = «взять из $project». Явная передача (включая null для sms) — проходит как есть.
*
* @param string|false $signalType false = взять из $project
* @param string|false|null $signalIdentifier false = взять из $project
* @param array<int, string>|false|null $smsSenders false = взять из $project
* @param string|false|null $smsKeyword false = взять из $project
*/
function insertSnapshotForTomorrow(
Project $project,
string|false $signalType = false,
string|false|null $signalIdentifier = false,
?int $dailyLimit = null,
?int $deliveryDaysMask = null,
string $regions = '{}',
array|false|null $smsSenders = false,
string|false|null $smsKeyword = false,
): void {
// ?: 'call' — проект без источника (preflight-фикстуры) не нарушает CHECK signal_type.
$type = $signalType === false ? ((string) $project->signal_type ?: 'call') : $signalType;
$identifier = $signalIdentifier === false ? $project->signal_identifier : $signalIdentifier;
$senders = $smsSenders === false ? $project->sms_senders : $smsSenders;
$keyword = $smsKeyword === false ? $project->sms_keyword : $smsKeyword;
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $tomorrow,
'project_id' => $project->id,
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10),
'delivery_days_mask' => $deliveryDaysMask ?? (int) ($project->delivery_days_mask ?? 127),
'regions' => $regions,
'signal_type' => $type,
'signal_identifier' => $identifier,
'sms_senders' => $senders === null ? null : json_encode($senders),
'sms_keyword' => $keyword,
'expected_volume' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10),
'delivered_count' => 0,
'created_at' => Date::now(),
]);
}
function createRoutingSnapshotFromProject(
Project $project,
?string $date = null,
string $signalType = 'call',
?string $signalIdentifier = null,
?int $dailyLimit = null,
string $regions = '{}',
): void {
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(),
'project_id' => $project->id,
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
'regions' => $regions,
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivered_count' => 0,
'created_at' => Date::now(),
]);
}