Files
portal/app/tests/Feature/ClientSms/JournalPagesTest.php
T
Дмитрий 22ac6e4f13 feat(смс-клиент): журнал рассылки открывается страницами по 50, а не одним куском
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная
разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч
номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул
из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и
человек стал открывать его чаще.

Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель.
На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер
страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы
возвращаемся туда, откуда ушли.

Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего
запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили
живьём 30 июля на базе номеров.

Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются
по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и
не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице
значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на
сервере, и на экране.

Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать.
Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит
вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом
другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не
доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд
возвращён.

Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и
не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт,
а не то поле, которое ты ждал.

Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы.
Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали —
просим первую.
2026-08-01 10:37:50 +03:00

180 lines
8.6 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\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Sms\SmsDeliveryState;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Журнал рассылки отдаётся СТРАНИЦАМИ (решение владельца В-203, «делай»).
*
* Зачем: до этого `show` отдавал ВСЕ сообщения рассылки одним куском. На рассылке в
* 20 000 номеров это 20 000 строк в одном ответе — ровно та мина, которую Этап 4 уже
* вынул из базы номеров. Этап 5 сделал её горячее: в журнал добавилась судьба, и
* человек стал открывать его чаще.
*
* 🔴 Итог по судьбам и предложение досыла считаются по ВСЕЙ рассылке, а не по видимой
* странице. Иначе человек, листнув на вторую страницу, увидел бы другой итог — и не
* понял бы, какому верить. Числа называет сервер (В-123), экран их не складывает.
*
* 🪤 В-167: сам по себе `paginate()` берёт номер страницы из ГЛОБАЛЬНОГО запроса
* приложения. По HTTP это то же самое, но проверка через `getJson` дыру не видит
* ВООБЩЕ — поэтому здесь тест спрашивает вторую страницу и сверяет, что строки
* ДРУГИЕ, а не просто «ответ пришёл».
*
* Помощники с префиксом pages* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
/** Рассылка с заданным числом сообщений. Судьбы — по кругу из $states. */
function pagesCampaign(int $tenantId, int $count, array $states = [null]): ClientSmsCampaign
{
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Страницы',
'body' => 'Текст рассылки',
'sender_name' => 'LIDERRA',
'source' => ClientSmsCampaign::SOURCE_MANUAL,
'status' => ClientSmsCampaign::STATUS_DONE,
'idempotency_key' => 'pages-'.uniqid(),
'segments' => 1,
'planned_count' => $count,
'sent_count' => $count,
'total_sms' => $count,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '8.50',
]);
for ($n = 1; $n <= $count; $n++) {
ClientSmsMessage::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => '7999'.str_pad((string) $campaign->id, 4, '0', STR_PAD_LEFT).str_pad((string) $n, 3, '0', STR_PAD_LEFT),
'operator' => 'mts',
'provider_key' => 'mts',
'status' => ClientSmsMessage::STATUS_SENT,
'delivery_status' => $states[($n - 1) % count($states)],
'cost_rub' => '8.50',
'segments' => 1,
]);
}
return $campaign;
}
function pagesUser(): User
{
$tenant = Tenant::factory()->create();
return User::factory()->create(['tenant_id' => $tenant->id]);
}
it('отдаёт первую страницу по 50 строк и называет, сколько всего', function () {
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
$response->assertOk();
expect($response->json('messages'))->toHaveCount(50)
->and($response->json('messages_page.total'))->toBe(120)
->and($response->json('messages_page.page'))->toBe(1)
->and($response->json('messages_page.per_page'))->toBe(50)
->and($response->json('messages_page.last_page'))->toBe(3);
});
it('вторая страница отдаёт ДРУГИЕ строки, а не те же самые', function () {
// 🔴 Главный тест этой задачи. Живой прогон 30.07 уже ловил вторую страницу,
// неотличимую от первой (В-167): `paginate()` брал номер страницы из глобального
// запроса, а не из этого. Сверяем именно НОМЕРА строк, а не факт ответа.
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120);
$first = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}?page=1");
$second = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}?page=2");
$firstIds = array_column($first->json('messages'), 'id');
$secondIds = array_column($second->json('messages'), 'id');
expect($secondIds)->toHaveCount(50)
->and($second->json('messages_page.page'))->toBe(2)
->and(array_intersect($firstIds, $secondIds))->toBe([]);
});
it('последняя страница отдаёт остаток', function () {
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}?page=3");
expect($response->json('messages'))->toHaveCount(20)
->and($response->json('messages_page.page'))->toBe(3);
});
it('размер страницы не задерёшь адресом — потолок 200', function () {
// Иначе страницы можно обойти одним параметром `?per_page=100000` и вернуться
// ровно туда, откуда ушли (В-166).
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}?per_page=100000");
expect($response->json('messages'))->toHaveCount(120)
->and($response->json('messages_page.per_page'))->toBe(200);
});
it('кривой размер страницы молча заменяется обычным', function () {
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 60);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}?per_page=0");
// Ругаться не за что: `per_page` набирает не человек, а экран.
expect($response->json('messages'))->toHaveCount(50)
->and($response->json('messages_page.per_page'))->toBe(50);
});
it('итог по судьбам считается по ВСЕЙ рассылке, а не по видимой странице', function () {
// 🔴 Иначе, листнув страницу, человек увидел бы другой итог и не понял бы,
// какому верить. Первая страница — сплошь «доставлено», а в рассылке есть и другие.
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120, [
SmsDeliveryState::DELIVERED,
SmsDeliveryState::NOT_DELIVERED,
]);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
expect($response->json('messages'))->toHaveCount(50)
->and($response->json('delivery.delivered'))->toBe(60)
->and($response->json('delivery.not_delivered'))->toBe(60);
});
it('предложение досыла считается по ВСЕЙ рассылке, а не по видимой странице', function () {
// Досыл — деньги. Считать «не дошедших» по видимой странице значило бы называть
// человеку заниженное число и брать за досыл не ту сумму.
$user = pagesUser();
$campaign = pagesCampaign((int) $user->tenant_id, 120, [
SmsDeliveryState::DELIVERED,
SmsDeliveryState::NOT_DELIVERED,
]);
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
expect($response->json('resend.can'))->toBeTrue()
->and($response->json('resend.count'))->toBe(60);
});
it('чужую рассылку страницами не открыть', function () {
$mine = pagesUser();
$stranger = pagesUser();
$campaign = pagesCampaign((int) $stranger->tenant_id, 60);
$this->actingAs($mine)->getJson("/api/sms/campaigns/{$campaign->id}?page=2")->assertNotFound();
});