Files
portal/app/tests/Feature/ClientSms/LargeCampaignTest.php
T

237 lines
11 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\ClientSmsCampaignPhone;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* Строка листа 2.9: рассылка на 20 000 номеров не подвешивает портал.
*
* Портал — это то, чего ЖДЁТ человек у экрана: предпросмотр сметы и создание
* рассылки. Сама отправка идёт в очереди, отдельным процессом, и читает снимок
* пачками (ClientSmsSnapshotReader) — экран её не ждёт.
*
* Меряем ДВЕ вещи, и обе — приборами, а не на глаз:
* · время ответа (грубый потолок — ловит квадрат и построчные запросы);
* · память ИМЕННО НА ЗАПРОСЕ, и не абсолютом, а РАЗНИЦЕЙ «маленькая рассылка →
* большая»: одинаковая для обеих цена самого запроса так вычитается.
* 🪤 Два капкана. Первый: без `memory_reset_peak_usage()` (PHP 8.2+) пик
* считается за весь процесс, и в полном прогоне его задаёт чужой тест.
* Второй: `memory_get_peak_usage(true)` — это память, взятая у системы; она
* не убывает, и после первого большого теста показывает одно и то же число
* на любом замере. Нужен именно счёт самого PHP — без аргумента.
*
* Пороги стоят между измеренным «до» и «после» — каждый проверен вырезанием
* (вернул старый вариант — тест краснеет).
*
* Помощники с префиксом bigSms* — имена функций в Pest ГЛОБАЛЬНЫЕ, совпадение
* с соседним файлом роняет весь прогон.
*
* Синтетические номера 7999…/7998… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
/**
* Быстрая заливка контактов в «свою базу» — пачками, без фабрик.
* $from — с какого номера продолжать: на паре «телефон + клиент» стоит запрет
* дублей, и вторая заливка с нуля упала бы на нём.
*/
function bigSmsContacts(int $tenantId, int $count, int $from = 0, string $prefix = '7999'): void
{
$now = now();
foreach (array_chunk(range($from, $from + $count - 1), 1000) as $chunk) {
$rows = [];
foreach ($chunk as $i) {
$rows[] = [
'tenant_id' => $tenantId,
'phone' => $prefix.str_pad((string) $i, 11 - strlen($prefix), '0', STR_PAD_LEFT),
'operator' => 'МТС',
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('client_sms_contacts')->insert($rows);
}
}
/** Быстрая заливка ЧУЖИХ номеров в общий стоп-лист портала. */
function bigSmsGlobalOptouts(int $count, string $prefix = '7998'): void
{
$now = now();
foreach (array_chunk(range(0, $count - 1), 1000) as $chunk) {
$rows = [];
foreach ($chunk as $i) {
$rows[] = [
'phone' => $prefix.str_pad((string) $i, 11 - strlen($prefix), '0', STR_PAD_LEFT),
'reason' => null,
'created_at' => $now,
];
}
DB::table('sms_global_optouts')->insert($rows);
}
}
/** Живая сделка в окне рассылки. */
function bigSmsDeal(int $tenantId, string $phone): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'received_at' => now()->subDay(),
]));
}
/** Тенант + вошедший пользователь + песочница. */
function bigSmsActor(): array
{
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
return [$user, $tenant];
}
it('2.9 — предпросмотр на 20 000 номерах отвечает и не съедает память', function () {
[$user, $tenant] = bigSmsActor();
$preview = fn () => $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'base',
]);
// Сначала маленькая рассылка — она задаёт «стоимость самого запроса» на этой
// машине (загрузка приложения, маршруты, шаблоны). Дальше меряем РАЗНИЦУ:
// сколько стоят именно 19 800 добавленных номеров.
bigSmsContacts($tenant->id, 200);
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$smallMb = memory_get_peak_usage() / 1024 / 1024;
bigSmsContacts($tenant->id, 19800, from: 200);
memory_reset_peak_usage();
$started = microtime(true);
$response = $preview();
$elapsed = microtime(true) - $started;
$bigMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf(
"\n[2.9] предпросмотр: 200 номеров %.0f МБ → 20 000 номеров %.0f МБ (разница %.0f МБ), %.2f с\n",
$smallMb, $bigMb, $bigMb - $smallMb, $elapsed,
));
$response->assertOk()->assertJsonPath('sendable_count', 20000);
// Измерено на этой машине: 37 МБ до правки, 18 МБ после (по два прогона на
// сторону). Порог посередине — краснеет, если 20 000 строк снова поднимут
// в память целиком.
expect($elapsed)->toBeLessThan(20.0)
->and($bigMb - $smallMb)->toBeLessThan(26.0);
});
it('2.9 — рассылка на 20 000 номеров создаётся, снимок полный', function () {
[$user, $tenant] = bigSmsActor();
bigSmsContacts($tenant->id, 20000);
// Отправку не выполняем: экран её НЕ ждёт — рассылка уходит в очередь, отдельным
// процессом. Здесь меряем ровно то, на что смотрит человек: ответ на «Отправить».
// 🪤 Без этого джоб выполняется внутри запроса, и в тестовой базе 20 000 вложенных
// транзакций (savepoint на каждое сообщение) валят PostgreSQL по max_locks (В-60).
Queue::fake();
memory_reset_peak_usage();
$started = microtime(true);
$response = $this->actingAs($user)->postJson('/api/sms/campaigns', [
'title' => 'Много',
'body' => 'Здравствуйте!',
'source' => 'base',
]);
$elapsed = microtime(true) - $started;
$peakMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf("\n[2.9] создание 20 000: %.2f с, пик %.0f МБ\n", $elapsed, $peakMb));
$response->assertCreated()->assertJsonPath('planned_count', 20000);
// Потолок времени здесь грубый: он ловит квадрат и построчные запросы, а не
// меряет победу. Измерено: 1.40 с до правок, 1.05 с после — разница слишком
// мала, чтобы называть её достижением, и в утверждение теста не вынесена.
// Память проверяется разницами в соседних тестах: абсолютный пик зависит от
// того, что делал процесс раньше, и в полном прогоне такой порог врал бы.
expect(ClientSmsCampaignPhone::where('campaign_id', $response->json('id'))->count())->toBe(20000)
->and($elapsed)->toBeLessThan(30.0);
Queue::assertPushed(SendClientSmsCampaignJob::class);
});
it('2.9 — огромный общий стоп-лист не тащится в память целиком', function () {
[$user, $tenant] = bigSmsActor();
bigSmsContacts($tenant->id, 200); // у клиента всего 200 своих номеров
$preview = fn () => $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'base',
]);
// Меряем не абсолютный потолок, а РАЗНИЦУ на одной и той же машине: сначала
// пустой общий стоп-лист, потом 50 000 ЧУЖИХ номеров в нём. Клиентские 200
// номеров не изменились — значит вся разница и есть цена лишнего чтения.
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$emptyMb = memory_get_peak_usage() / 1024 / 1024;
bigSmsGlobalOptouts(50000);
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$hugeMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf(
"\n[2.9] чужой стоп-лист: пусто %.0f МБ → 50 000 записей %.0f МБ (разница %.0f МБ)\n",
$emptyMb, $hugeMb, $hugeMb - $emptyMb,
));
// Чужие 50 000 номеров к этой рассылке отношения не имеют: спрашивать надо
// только про свои 200. Измерено: +27 МБ до правки, +4 МБ после.
expect($hugeMb - $emptyMb)->toBeLessThan(10.0);
});
it('2.9 — удалённая сделка в получатели не попадает', function () {
[$user, $tenant] = bigSmsActor();
// Сторож к правке этой же задачи: аудитория теперь читается «сырыми» строками,
// без сборки моделей. Правило «удалённые сделки не берём» живёт в модели, и
// если оно при таком чтении потеряется, СМС уйдёт человеку, чью сделку удалили.
$alive = bigSmsDeal($tenant->id, '79995551111');
$deleted = bigSmsDeal($tenant->id, '79995552222');
$deleted->delete();
$response = $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 30,
]);
$response->assertOk()->assertJsonPath('sendable_count', 1);
expect($alive->trashed())->toBeFalse()
->and($deleted->fresh()->trashed())->toBeTrue();
});