Files
portal/app/tests/Feature/ClientSms/ResendCampaignTest.php
T
Дмитрий b6a15c5bbf merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.

Девять столкновений, каждое разобрано по существу.

Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.

Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.

Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.

Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.

Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.

СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.

Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
  - 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
    перенесены из рабочей ветки, где владелец их уже принял;
  - остальные 42 - свои, в новом коде ветки, и починены по существу:
    задвоенный ключ массива в трёх тестах (след копирования - комментарий
    оторвался от своей строки), врущие описания двух помощников (PHP сам
    делает из ключа-номера число), сужение типа возврата, прятавшее от
    анализатора свойства подставного отправителя, лишний знак вопроса и
    четыре бесполезных перенумерования списка.
  - Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
    поимённо и покраснел; убрал - ноль.

Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.

Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.

Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.

Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.

NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:50:41 +03:00

262 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<int|string, string|null> $fates номер => судьба (null — ещё не спрашивали)
*
* Ключ описан как int|string намеренно: PHP сам превращает ключ-номер «79995550001»
* в целое число, поэтому обещание «только строка» было ложным. Внутри номер всё
* равно приводится к строке — поведение не меняется.
*/
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);
});