f70527c3df
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию. finalize доводил боевой запуск до кассы кабинета и уходил (launched:false, stoppedAt:'payment'), денежные кнопки ему были запрещены наглухо, а «реальная оплата» отложена на «Сессию 6», которой не случилось. Песочницу при этом выключили 02.08 в 07:40. Итог на бою: клиент жал «Запустить» → у него списывалась ВСЯ смета по размеру списка → в кабинете оставался неоплаченный черновик → на модерацию он не уходил → реклама не показывалась ни разу → деньги не возвращались никогда (статус draft_ready терминальный, возврата не имеет). То есть беда была не «клиент переплачивает разницу», как записали накануне, а «клиент платит сто процентов ни за что». Слепки установленного у владельца робота совпали с веткой до буквы — на боевой машине тот же код. Владелец решил: боевой не трогать (стоит как стоит), роботу денежную кнопку разрешить, но с потолком. ── Робот теперь платит ──────────────────────────────────────────────────────── submitWithPayment на шаге /payment: сперва ЧИТАЕТ сумму к оплате, потом гонит её через гейт против меньшего из двух потолков (лимит кампании и общий потолок робота), и только потом ищет кнопку и жмёт. Сумма не прочиталась — не платим: не знаем, что списываем. Не ушли со /payment после клика — падаем громко, портал по отказу отпустит заморозку. Общий чёрный список денежных кнопок НЕ ослаблен: та же кнопка остаётся запретной для всех прочих путей, включая пересдачу. Разрешение точечное. Прочитанная сумма — это НАШИ расходы у МТС; она едет в портал полем actualCostRub, которое до сих пор было пустой заготовкой. ── Кошелёк вместо общего баланса ────────────────────────────────────────────── Порядок зеркалит сам МТС (билинг снят живьём 27–28.07, FLOW-FINDINGS «Задача 2.0»: кабинет резервирует сумму, окончательно списывает по факту показов, остаток возвращает): запуск → морозим смету на ad_wallets (канал telegram) робот заплатил → фактическая сумма легла в кампанию (mts_cost_rub, v9.66) модерация «да» → списываем по факту × наценка, остаток отпускаем модерация «нет» → отпускаем всё, ни рубля не списано сбой до кабинета → отпускаем всё Заморозка была убрана 29.07 намеренно — тогда рассуждали «сумма известна в момент запуска, морозить нечего». Рассуждение верно ровно до вопроса владельца: сумма известна, а сколько человек из списка вообще есть в телеграме — нет. Факта нет, а модерация одобрила — НЕ списываем ничего и кричим в журнал. Списать «по оценке» значило бы вернуть ровно ту беду, ради которой всё и делалось. Идемпотентность больше не самодельная: бронь уникальна по кампании, списание — по ключу события. Прежнее «сальдо проводок» стало не нужно, CampaignChargeServiceTest удалён — его предмет (charge/refund по общему балансу) больше не существует, замена KoshelekTelegramaTest. ── Экран ────────────────────────────────────────────────────────────────────── Карточка денег показывала общий баланс портала. После переезда это стало прямым враньём: клиент видел бы «денег хватает» там, где запуск отвечает 409. Теперь ручка отдаёт СВОБОДНЫЕ деньги кошелька (баланс минус заморозка) и заморозку отдельной графой, а кнопка пополнения ведёт в рекламный кошелёк — прежняя клала бы деньги в общий баланс, и запустить рекламу всё равно было бы нельзя. ── Проверено вырезанием, а не только зелёным ───────────────────────────────── - убрал запрет «сумма не прочитана» — покраснели 2 датчика оплаты; - вернул списание по смете вместо факта — датчик поймал 315 ₽ там, где должно быть 210 ₽. Замеры: телеграм-модуль 330/330, вместе с рекламой и СМС 1078/1078, экраны 251 файл / 2033 теста / 0 падений, робот 160/160, статанализ 0, типы 0, формат чисто. Сторож денег под боевой ролью переписан: класс «тихий ноль» закрылся сам — AdWalletService ставит контекст клиента сам, а не надеется на вызывающего. 🔴 На боевой НЕ выкачено. Осталось открытым: показать клиенту строкой «заморожено / списано по факту / возвращено» на карточке кампании; пересдача по-прежнему шлёт «без оплаты» (кампания вернётся на модерацию неоплаченной); числа 0,720 и 0,816 за показ с медиа так и не замерены живьём. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
14 KiB
PHP
334 lines
14 KiB
PHP
<?php
|
||
|
||
use App\Models\AdWallet;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
|
||
use App\Services\External\BalanceProvider;
|
||
use App\Services\External\BalanceReading;
|
||
use App\Services\External\LivenessProbe;
|
||
use App\Services\External\LivenessReading;
|
||
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');
|
||
|
||
// Unit/Sms: провайдеры пишут в журнал (Log::), а без контейнера у фасада нет корня.
|
||
// БД этим тестам не нужна — только мотор Laravel.
|
||
pest()->extend(TestCase::class)->in('Unit/Sms');
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| 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()
|
||
{
|
||
// ..
|
||
}
|
||
|
||
/** Стаб балансового провайдера с заранее заданным результатом (внешние сервисы). */
|
||
function fakeProvider(string $key, BalanceReading $reading): BalanceProvider
|
||
{
|
||
return new class($key, $reading) implements BalanceProvider
|
||
{
|
||
public function __construct(private string $key, private BalanceReading $reading) {}
|
||
|
||
public function serviceKey(): string
|
||
{
|
||
return $this->key;
|
||
}
|
||
|
||
public function fetch(): BalanceReading
|
||
{
|
||
return $this->reading;
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Стаб-проба живости с заранее заданным результатом (внешние сервисы). */
|
||
function fakeProbe(string $key, LivenessReading $reading): LivenessProbe
|
||
{
|
||
return new class($key, $reading) implements LivenessProbe
|
||
{
|
||
public function __construct(private string $key, private LivenessReading $reading) {}
|
||
|
||
public function serviceKey(): string
|
||
{
|
||
return $this->key;
|
||
}
|
||
|
||
public function check(): LivenessReading
|
||
{
|
||
return $this->reading;
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Читает фикстуру автоподбора из 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.
|
||
*
|
||
* Активная дата по умолчанию — зеркало LeadRouter::activeSnapshotDate() через
|
||
* testActiveSnapshotDate() (после 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(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Активная дата слепка — зеркало LeadRouter::activeSnapshotDate(): после 21:00 МСК
|
||
* завтра, до — сегодня (вечерний переворот заливки). Помощник обязан класть снимок
|
||
* на ТУ ЖЕ дату, которую спросит роутер, иначе вечерние прогоны (после 21:00 МСК)
|
||
* не находят снимок и весь пласт тестов доставки падает. См. SnapshotHelperTimeOfDayTest.
|
||
*/
|
||
function testActiveSnapshotDate(): string
|
||
{
|
||
$msk = Carbon::now('Europe/Moscow');
|
||
|
||
return $msk->hour >= 21
|
||
? $msk->copy()->addDay()->toDateString()
|
||
: $msk->toDateString();
|
||
}
|
||
|
||
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 ?? testActiveSnapshotDate(),
|
||
'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(),
|
||
]);
|
||
}
|
||
|
||
/*
|
||
|--------------------------------------------------------------------------
|
||
| Рекламный кошелёк телеграм-рекламы
|
||
|--------------------------------------------------------------------------
|
||
|
|
||
| С 03.08.2026 телеграм-реклама живёт на рекламном кошельке (ad_wallets) с
|
||
| заморозкой, а не на общем балансе тенанта: запуск морозит смету, одобрение
|
||
| модерацией списывает по факту кабинета МТС, отказ отпускает всё. Помощники
|
||
| ниже избавляют тесты от повторения одной и той же подготовки.
|
||
|
|
||
*/
|
||
|
||
/** Пополнить рекламный кошелёк тенанта — без него любой запуск упрётся в «нет денег». */
|
||
function tgKoshelekTopup(int $tenantId, string $amountRub = '10000.00'): void
|
||
{
|
||
app(AdWalletService::class)
|
||
->topup($tenantId, $amountRub, 'telegram', 'Пополнение для теста');
|
||
}
|
||
|
||
/** Всего денег на рекламном кошельке (включая замороженные). */
|
||
function tgKoshelekBalance(int $tenantId): string
|
||
{
|
||
return (string) (AdWallet::where('tenant_id', $tenantId)->value('balance_rub') ?? '0.00');
|
||
}
|
||
|
||
/** Сколько заморожено под кампании. */
|
||
function tgKoshelekFrozen(int $tenantId): string
|
||
{
|
||
return (string) (AdWallet::where('tenant_id', $tenantId)->value('frozen_rub') ?? '0.00');
|
||
}
|