Files
portal/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php
T

123 lines
7.2 KiB
PHP
Raw Normal View History

<?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);
});