77f61fb1d1
Слияние 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>
123 lines
7.2 KiB
PHP
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);
|
|
});
|