Files
portal/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php
T
Дмитрий 77f61fb1d1
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.

Сверх самого слияния:

- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
  переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
  о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
  телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
  возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
  контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
  увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
  есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
  Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
  в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
  дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.

Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:38 +03:00

123 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* 🔴 КЛАСС «тихий ноль». Обычные тесты ходят СУПЕРЮЗЕРОМ, который обходит защиту по
* клиентам, и потому физически не могут увидеть дыру этого класса. На бою очередь
* работает под обычной ролью `crm_app_user`, а защита денежных таблиц устроена строго:
*
* balance_transactions: tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
* tenants: id = current_setting('app.current_tenant_id', true)::bigint
*
* Телеграмные деньги идут с ОБЩЕГО баланса тенанта, и контекст клиента ставит не сам
* сервис, а вызывающий джоб (`tenantTx()` в PollTelegramModerationJob и соседях).
* Значит проверять надо две вещи, и обе — под боевой ролью:
*
* 1. С контекстом (как зовёт джоб) деньги реально двигаются — нет ни «тихого нуля»
* от RLS, ни отказа из-за забытого GRANT.
* 2. Без контекста работа НЕ проходит молча. Молчаливый пропуск здесь означал бы, что
* возврат за отклонённую модерацию просто не случился: `hasOutstandingCharge()`
* увидел бы ноль строк, `refund()` честно вышел бы, и деньги клиента остались бы
* у нас — без ошибки и без строки в журнале.
*
* Зеркало сторожа рекламного кошелька — tests/Feature/Advertising/AdWalletUnderRealRoleTest.php.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
// 🪤 Тестовая база собирается миграциями, а права ролям на проде раздаёт отдельный
// скрипт `db/02_grants.sql` (SELECT/INSERT/UPDATE/DELETE на ВСЕ таблицы схемы).
// Без него `crm_app_user` спотыкается о «нет доступа к таблице tenants» — и прибор
// мерил бы отсутствующий грант вместо защиты по клиентам. Выдаём права так же, как
// на бою, чтобы под ролью проверялась именно RLS.
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_app_user');
DB::statement('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_app_user');
});
/** Выполнить под боевой ролью портала (не суперюзером). */
function podTgBoevoyRolyu(callable $chto): mixed
{
DB::statement('SET ROLE crm_app_user');
try {
return $chto();
} finally {
DB::statement('RESET ROLE');
}
}
/** Кампания в песочнице, деньги на общем балансе клиента. */
function tgDenezhnayaKampaniya(string $balanceRub = '5000.00'): Campaign
{
$tenant = Tenant::factory()->create(['balance_rub' => $balanceRub]);
return Campaign::create([
'tenant_id' => $tenant->id,
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'status' => Campaign::STATUS_MODERATING,
]);
}
it('списывает под боевой ролью с контекстом клиента — как это делает джоб запуска', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) {
DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id);
$svc->charge($campaign, '1200.00');
}));
// Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем.
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('3800.00');
expect(BalanceTransaction::where('related_id', $campaign->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
});
it('возвращает под боевой ролью с контекстом клиента — как это делает джоб модерации', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '1200.00');
// Модерация МТС отклонила кампанию: джоб возвращает клиенту всю смету.
podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) {
DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id);
$svc->refund($campaign);
}));
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('5000.00');
expect(BalanceTransaction::where('related_id', $campaign->id)
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)->count())->toBe(1);
});
it('без контекста клиента возврат под боевой ролью падает ГРОМКО, а не молчит', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '1200.00');
$balanceDo = (string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub;
// Ровно та же работа, но джоб забыл поставить контекст. Сторож требует ошибку:
// молчаливый выход означал бы «деньги не вернулись, и никто не узнал».
//
// 🪤 Держится это на ОДНОЙ букве: база без контекста молча отдаёт ноль строк
// (`id = NULL` в политике `tenants_self_isolation` не совпадает ни с чем), и громким
// это делает только `firstOrFail()` в сервисе. Заменить его на `first()` — и возврат
// снова станет тихим. Поэтому проверяем именно исключение поиска тенанта.
expect(fn () => podTgBoevoyRolyu(fn () => $svc->refund($campaign)))
->toThrow(ModelNotFoundException::class);
// И деньги при этом не тронуты — ни возврата, ни половинчатого движения.
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe($balanceDo);
});