237 lines
11 KiB
PHP
237 lines
11 KiB
PHP
|
|
<?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();
|
|||
|
|
});
|