Files
portal/app/tests/Feature/ClientSms/AdminTariffTest.php
T
Дмитрий 1d292082ab feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.

Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.

Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.

Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.

Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:30:03 +03:00

186 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ClientSmsSettings;
use App\Models\ClientSmsTariff;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/**
* Task 8: SaaS-admin «Клиентские СМС» — тарифная сетка (client_sms_tariffs)
* и общие настройки (client_sms_settings, singleton).
*
* Сидированы миграциями: 5 ступеней (1/9.00, 100/8.50, 250/8.30, 500/8.10,
* 1000/8.00) + settings (fee 2500.00, grace 29 дней).
*/
test('GET /api/admin/sms/tariffs возвращает сидированные ступени и настройки', function () {
$r = $this->getJson('/api/admin/sms/tariffs');
$r->assertStatus(200);
$tariffs = $r->json('tariffs');
expect($tariffs)->toHaveCount(5);
expect(collect($tariffs)->pluck('min_qty')->all())->toBe([1, 100, 250, 500, 1000]);
expect($tariffs[0]['price_rub'])->toBe('9.00');
expect($r->json('settings.name_fee_rub_per_operator'))->toBe('2500.00');
expect($r->json('settings.name_debt_grace_days'))->toBe(29);
});
test('PUT /api/admin/sms/tariffs заменяет весь набор ступеней', function () {
$r = $this->putJson('/api/admin/sms/tariffs', [
'rows' => [
['min_qty' => 1, 'price_rub' => '10.00'],
['min_qty' => 500, 'price_rub' => '7.50'],
],
]);
$r->assertStatus(200);
expect(ClientSmsTariff::count())->toBe(2);
$rows = ClientSmsTariff::orderBy('min_qty')->get();
expect($rows[0]->min_qty)->toBe(1);
expect($rows[0]->price_rub)->toBe('10.00');
expect($rows[1]->min_qty)->toBe(500);
expect($rows[1]->price_rub)->toBe('7.50');
$responseTariffs = $r->json('tariffs');
expect($responseTariffs)->toHaveCount(2);
expect(collect($responseTariffs)->pluck('min_qty')->all())->toBe([1, 500]);
});
test('PUT /api/admin/sms/settings обновляет singleton настроек', function () {
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '3000.00',
'name_debt_grace_days' => 14,
]);
$r->assertStatus(200);
expect($r->json('name_fee_rub_per_operator'))->toBe('3000.00');
expect($r->json('name_debt_grace_days'))->toBe(14);
$settings = ClientSmsSettings::first();
expect($settings->name_fee_rub_per_operator)->toBe('3000.00');
expect($settings->name_debt_grace_days)->toBe(14);
});
test('PUT /api/admin/sms/tariffs с некорректной ступенью → 422', function () {
$r = $this->putJson('/api/admin/sms/tariffs', [
'rows' => [
['min_qty' => 0, 'price_rub' => '10.00'],
],
]);
$r->assertStatus(422);
$r2 = $this->putJson('/api/admin/sms/tariffs', [
'rows' => [
['min_qty' => 1, 'price_rub' => '0.00'],
],
]);
$r2->assertStatus(422);
// Исходные 5 ступеней не тронуты.
expect(ClientSmsTariff::count())->toBe(5);
});
/**
* Строка листа 4.3: срок, после которого рассылка считается зависшей, правит
* владелец — без выпуска новой версии. Значение живёт в тех же общих настройках,
* что и границы окна отправки.
*/
test('4.3 — владелец меняет срок «рассылка зависла», и он сохраняется', function () {
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'stuck_after_minutes' => 120,
]);
$r->assertStatus(200);
expect($r->json('stuck_after_minutes'))->toBe(120);
expect(ClientSmsSettings::first()->stuck_after_minutes)->toBe(120);
});
test('4.3 — GET отдаёт срок «рассылка зависла» вместе с остальными настройками', function () {
$r = $this->getJson('/api/admin/sms/tariffs');
$r->assertStatus(200);
expect($r->json('settings.stuck_after_minutes'))->toBe(60);
});
test('4.3 — слишком короткий срок админка не принимает: сторож срывал бы живые рассылки', function () {
$base = ['name_fee_rub_per_operator' => '2500.00', 'name_debt_grace_days' => 29];
$this->putJson('/api/admin/sms/settings', $base + ['stuck_after_minutes' => 0])
->assertStatus(422)
->assertJsonValidationErrors('stuck_after_minutes');
$this->putJson('/api/admin/sms/settings', $base + ['stuck_after_minutes' => 4])
->assertStatus(422);
// Срок не тронут.
expect(ClientSmsSettings::first()->stuck_after_minutes)->toBe(60);
});
test('4.3 — срок дольше суток админка не принимает: деньги висели бы замороженными', function () {
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'stuck_after_minutes' => 1441,
]);
$r->assertStatus(422);
expect(ClientSmsSettings::first()->stuck_after_minutes)->toBe(60);
});
/**
* Строка листа 4.6: потолок загрузки базы правит владелец (журнал В-161) —
* число, которое никто не может поменять, хуже отсутствующего (мой вывод В-84).
*/
test('4.6 — владелец меняет потолок загрузки базы, и он сохраняется', function () {
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 29,
'max_upload_phones' => 30000,
]);
$r->assertStatus(200);
expect($r->json('max_upload_phones'))->toBe(30000);
expect(ClientSmsSettings::first()->max_upload_phones)->toBe(30000);
});
test('4.6 — GET отдаёт потолок загрузки вместе с остальными настройками', function () {
$r = $this->getJson('/api/admin/sms/tariffs');
$r->assertStatus(200);
expect($r->json('settings.max_upload_phones'))->toBe(50000);
});
test('4.6 — потолок вне границ админка не принимает', function () {
$base = ['name_fee_rub_per_operator' => '2500.00', 'name_debt_grace_days' => 29];
// Слишком мало: обычную базу пришлось бы резать на десяток файлов.
$this->putJson('/api/admin/sms/settings', $base + ['max_upload_phones' => 100])
->assertStatus(422)
->assertJsonValidationErrors('max_upload_phones');
// Слишком много: файл читается в память целиком.
$this->putJson('/api/admin/sms/settings', $base + ['max_upload_phones' => 100001])
->assertStatus(422);
// Потолок не тронут.
expect(ClientSmsSettings::first()->max_upload_phones)->toBe(50000);
});
test('4.3 — кто присылает только настройки имени, продолжает работать', function () {
// Контракт адреса менять права не имеем: срок необязателен, как и границы окна.
$r = $this->putJson('/api/admin/sms/settings', [
'name_fee_rub_per_operator' => '3000.00',
'name_debt_grace_days' => 20,
]);
$r->assertStatus(200);
expect(ClientSmsSettings::first()->stuck_after_minutes)->toBe(60);
});