Files
portal/app/tests/Feature/ClientSms/ResendCampaignTest.php
T
Дмитрий c65856ec73 feat(смс-клиент): досыл не дошедшим — отдельной рассылкой «Досыл к рассылке №N»
Строка листа 5.6. В журнале рассылки появилась кнопка «Дослать не дошедшим (N)».
Нажатие спрашивает отдельным окном: сколько человек получат сообщение ещё раз и что
это отдельная рассылка с отдельной оплатой. По подтверждению заводится рассылка
«Досыл к рассылке №N» — у неё свои деньги и свой итог, а первая остаётся целой.

Почему отдельной рассылкой, а не второй попыткой в старой (решение владельца В-200):
переписав старые строки, мы стёрли бы «не доставлено», и человек больше не увидел бы,
что первая попытка не дошла. Да и база второй попытки не даст: на журнале сообщений
уникальный ключ по клиенту, рассылке и номеру.

Досылаем только по ОКОНЧАТЕЛЬНОМУ итогу (моё решение В-216): пока кто-то «ещё в пути»
или пока сама рассылка идёт — кнопки нет, а сервер отказывает человеческими словами.
Правило живёт в одном месте на сервере, экран получает готовый признак и условие не
пересчитывает: разойдись они — человек нажимал бы кнопку и получал отказ.

Деньги. Досыл списывается как обычная отправка (решение В-198) и своей денежной ветки
не имеет — считает, замораживает и списывает тот же общий код заказа. Повторное
нажатие второй рассылки НЕ заводит: ключ заказа постоянный, «resend:{номер}». Второй
клик означал бы вторую оплаченную рассылку тем же людям.

Живой прогон нашёл то, чего не видели тесты (В-227): досыл забывал оператора, которого
портал уже знал. Номер уходил как «вписанный руками», получал пометку «оператора не
знаем», а такую строку обогащение регионом уже не чинит — на бою, где универсального
канала может не быть, досыл не ушёл бы никому при созданной и оплаченной рассылке.
Теперь досыл вспоминает оператора из журнала сообщений первой рассылки: журнал хранится
без срока, в отличие от снимка получателей, который чистится через 90 дней.

Ещё две дыры, которых не было в плане. Первая: «в пути» считается только по
отправленному, поэтому у рассылки в работе он равен нулю — досыл разрешался бы посреди
работы (В-229). Вторая: защита от случайного повтора отбивала бы досыл молча, ведь текст
у него тот же, что у первой рассылки (В-228).

Схема v9.25: колонка resend_of_campaign_id и индекс у client_sms_campaigns. Прав не
требует — это колонка существующей таблицы, а не новая таблица.

Тесты: 8 на сервере, 4 на экране. Все проверены вырезом — двенадцать вырезов, каждый
покрасил именно свои тесты. Живой прогон в браузере: кнопка с числом, окно про отдельную
оплату, рассылка на два нужных номера с сохранённым оператором, первая рассылка цела,
второе нажатие ничего не завело. Стенд возвращён.

Движение денег живьём НЕ проверялось: на стенде включена песочница, а выключить её —
значит слать настоящие СМС за настоящие деньги.
2026-07-31 16:03:35 +03:00

258 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\Sms\SmsDeliveryState;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Строка листа 5.6: «дослать не дошедшие» работает по НАСТОЯЩЕЙ судьбе сообщения,
* а не по факту передачи оператору.
*
* Решение владельца В-200: досыл — ОТДЕЛЬНАЯ рассылка «Досыл к рассылке №N», старая
* запись цела. Переписав старые строки, мы стёрли бы «не доставлено», и человек больше
* не увидел бы, что первая попытка не дошла. Да и физически не вышло бы: на журнале
* уникальный ключ (клиент, рассылка, номер) — В-184.
*
* Моё решение В-216: досыл даём ТОЛЬКО по окончательному итогу. Пока хоть кто-то «ещё
* в пути», второе сообщение уехало бы человеку, которому первое как раз довозят, — и
* клиент заплатил бы дважды за одного получателя.
*
* Помощники с префиксом resend* — имена функций в Pest ГЛОБАЛЬНЫЕ на весь прогон.
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
// Окно 10–20 по местному времени: 09:00 UTC = полдень в Москве, окно открыто
// (тот же приём, что в ResumeCampaignTest). Время фиксируем ещё и потому, что
// заказ рассылки смотрит на «похожую за последние 10 минут».
$this->travelTo(CarbonImmutable::parse('2026-08-15 09:00', 'UTC'));
// 🪤 Очередь в тестах `sync`: без заглушки джоб отправки поехал бы прямо внутри
// теста и мы проверяли бы отправку, а не досыл.
Queue::fake();
});
/**
* Готовая рассылка с уже известной судьбой каждого номера.
*
* @param array<string, string|null> $fates номер => судьба (null — ещё не спрашивали)
*/
function resendParent(int $tenantId, array $fates, string $status = ClientSmsCampaign::STATUS_DONE): ClientSmsCampaign
{
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Первая рассылка',
'body' => 'Здравствуйте! Это первая рассылка.',
'sender_name' => 'LIDERRA',
'source' => ClientSmsCampaign::SOURCE_MANUAL,
'status' => $status,
'idempotency_key' => 'resend-parent-'.uniqid(),
'segments' => 1,
'planned_count' => count($fates),
'sent_count' => count($fates),
'total_sms' => count($fates),
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '8.50',
]);
foreach ($fates as $phone => $fate) {
ClientSmsMessage::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => (string) $phone,
'provider_key' => 'mts',
'status' => ClientSmsMessage::STATUS_SENT,
'delivery_status' => $fate,
'cost_rub' => '8.50',
'segments' => 1,
]);
}
return $campaign;
}
/** Клиент с деньгами на кошельке и вошедший в кабинет человек. */
function resendClient(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
app(AdWalletService::class)->topup((int) $tenant->id, '1000.00', null, 'прогон досыла');
return [$tenant, $user];
}
it('досыл берёт только НЕ ДОШЕДШИЕ номера и заводит отдельную рассылку', function () {
[$tenant, $user] = resendClient();
$parent = resendParent((int) $tenant->id, [
'79995550001' => SmsDeliveryState::DELIVERED,
'79995550002' => SmsDeliveryState::NOT_DELIVERED,
'79995550003' => SmsDeliveryState::NOT_SENT,
]);
$response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend");
$response->assertCreated();
$child = ClientSmsCampaign::where('tenant_id', $tenant->id)
->where('resend_of_campaign_id', $parent->id)
->firstOrFail();
expect($child->title)->toBe("Досыл к рассылке №{$parent->id}");
expect($child->body)->toBe($parent->body);
expect((int) $child->planned_count)->toBe(2);
// 🔴 Главная проверка: не «сколько», а КТО. Число 2 могло совпасть случайно —
// например, если бы досыл взял доставленного и потерял одного недошедшего.
expect(ClientSmsCampaignPhone::where('campaign_id', $child->id)->orderBy('phone')->pluck('phone')->all())
->toBe(['79995550002', '79995550003']);
// Старая запись цела: «не доставлено» из отчёта не затирается (В-200).
expect(ClientSmsMessage::where('campaign_id', $parent->id)->count())->toBe(3);
expect(ClientSmsMessage::where('campaign_id', $parent->id)
->where('phone', '79995550002')->value('delivery_status'))
->toBe(SmsDeliveryState::NOT_DELIVERED);
});
it('досыл помнит оператора, которого портал уже знал по первой рассылке', function () {
// 🔴 Мина, найденная живым прогоном: начни досыл с чистого листа — номер попал бы
// в «оператора не знаем», а такую строку обогащение регионом уже НЕ чинит (оно
// берёт только строки без причины отказа). На бою, где универсального канала может
// не быть, досыл не ушёл бы НИКОМУ, а деньги за рассылку списались бы.
[$tenant, $user] = resendClient();
$parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::NOT_DELIVERED]);
ClientSmsMessage::where('campaign_id', $parent->id)->update(['operator' => 'mts']);
$this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertCreated();
$child = ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->firstOrFail();
expect(ClientSmsCampaignPhone::where('campaign_id', $child->id)->value('operator'))->toBe('mts');
});
it('досылать нечего — говорит человеческими словами и рассылку не заводит', function () {
[$tenant, $user] = resendClient();
$parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::DELIVERED]);
$response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend");
$response->assertStatus(422);
expect($response->json('message'))->toContain('нечего');
expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0);
});
it('пока часть номеров ещё в пути — досыл не даём, чтобы не слать дважды', function () {
[$tenant, $user] = resendClient();
$parent = resendParent((int) $tenant->id, [
'79995550001' => SmsDeliveryState::NOT_DELIVERED,
'79995550002' => SmsDeliveryState::SENDING,
// Судьбу этого ещё не спрашивали — для человека это то же «ещё в пути» (В-214).
'79995550003' => null,
]);
$response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend");
$response->assertStatus(409);
expect($response->json('message'))->toContain('в пути');
expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0);
});
it('пока сама рассылка ещё идёт — досыл не даём: часть номеров даже не отправлена', function () {
// 🔴 Дыра, которой не было в плане: «ещё в пути» считается только по ОТПРАВЛЕННЫМ.
// У рассылки в работе остальные номера лежат как `pending` — они в этот счёт не
// попадают, и досыл разрешился бы посреди работы, ещё до того как оператор вообще
// получил половину списка.
[$tenant, $user] = resendClient();
$parent = resendParent(
(int) $tenant->id,
['79995550001' => SmsDeliveryState::NOT_DELIVERED],
ClientSmsCampaign::STATUS_SENDING,
);
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $parent->id,
'phone' => '79995550002',
'status' => ClientSmsMessage::STATUS_PENDING,
'segments' => 1,
]);
$response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend");
$response->assertStatus(409);
expect($response->json('message'))->toContain('ещё идёт');
expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0);
});
it('повторное нажатие не заводит вторую рассылку и не берёт денег дважды', function () {
// Это ДЕНЬГИ: двойной клик по «Дослать» означал бы вторую оплаченную рассылку
// тем же людям. Защита та же, что у обычного заказа, — ключ заказа (строка 1.18).
[$tenant, $user] = resendClient();
$parent = resendParent((int) $tenant->id, [
'79995550001' => SmsDeliveryState::NOT_DELIVERED,
'79995550002' => SmsDeliveryState::NOT_SENT,
]);
$this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertCreated();
$this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertSuccessful();
expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(1);
});
it('карточка рассылки сама говорит экрану, показывать ли кнопку досыла и на сколько номеров', function () {
// Урок В-146: правило живёт в ОДНОМ месте, на сервере. Экран рисует кнопку по
// готовому признаку и условие не пересчитывает — иначе человек нажимал бы кнопку
// и получал отказ.
[$tenant, $user] = resendClient();
$done = resendParent((int) $tenant->id, [
'79995550001' => SmsDeliveryState::DELIVERED,
'79995550002' => SmsDeliveryState::NOT_DELIVERED,
'79995550003' => SmsDeliveryState::NOT_SENT,
]);
$this->actingAs($user)->getJson("/api/sms/campaigns/{$done->id}")
->assertOk()
->assertJsonPath('resend.can', true)
->assertJsonPath('resend.count', 2);
$waiting = resendParent((int) $tenant->id, [
'79995550004' => SmsDeliveryState::NOT_DELIVERED,
'79995550005' => SmsDeliveryState::SENDING,
]);
$this->actingAs($user)->getJson("/api/sms/campaigns/{$waiting->id}")
->assertOk()
->assertJsonPath('resend.can', false)
->assertJsonPath('resend.count', 0);
});
it('чужую рассылку дослать нельзя', function () {
[$tenant] = resendClient();
$parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::NOT_DELIVERED]);
$stranger = Tenant::factory()->create();
$strangerUser = User::factory()->create(['tenant_id' => $stranger->id]);
$this->actingAs($strangerUser)
->postJson("/api/sms/campaigns/{$parent->id}/resend")
->assertStatus(404);
expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0);
});