Files
portal/app/tests/Pest.php
T
Дмитрий f70527c3df fix,деньги: телеграм-реклама переведена на рекламный кошелёк с заморозкой — робот наконец платит МТС
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию.
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>
2026-08-03 12:31:21 +03:00

334 lines
14 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\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');
}