$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(); });