22ac6e4f13
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и человек стал открывать его чаще. Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель. На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы возвращаемся туда, откуда ушли. Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили живьём 30 июля на базе номеров. Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на сервере, и на экране. Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать. Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд возвращён. Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт, а не то поле, которое ты ждал. Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы. Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали — просим первую.
180 lines
8.6 KiB
PHP
180 lines
8.6 KiB
PHP
<?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();
|
||
});
|