Files
portal/app/tests/Feature/ClientSms/SnapshotRegionTest.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

392 lines
20 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\Jobs\EnrichClientSmsSnapshotRegionJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsMessage;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\SmsQuietHours;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строка листа 3.3 до конца: про номер, вписанный руками, мы не знаем НИЧЕГО —
* ни в каком он регионе, ни чей он. И то, и другое спрашиваем у ДаДаты одним
* вопросом (решение владельца В-88), а до ответа номер не уходит и не теряется:
* он ждёт уточнения прямо в снимке (моё решение В-89).
*
* Не узнали за сутки — окончательная причина «не знаем, в каком регионе номер»,
* и рассылка на этом честно заканчивается: деньги размораживаются, причина видна.
*
* Тесты парные (урок В-54): рядом с «не ушло» обязан стоять прогон «а вот теперь
* ушло», иначе ноль мог получиться просто от поломки.
*
* Помощники с префиксом snapRegion* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* 🪤 SharesSupplierPdo — команда добора перечисляет рассылки служебным соединением
* (BYPASSRLS): без общего PDO оно не видит незакоммиченные данные теста (В-95).
*
* Синтетические номера 7999… — реальные НИКОГДА. ДаДата всюду заглушена: живой
* ключ не трогаем и наших денег не тратим.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
// ДаДата включена (ключ задан) — иначе обогащение не стартует вовсе.
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
Cache::flush(); // чистый дневной бюджет ДаДаты
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
// 🪤 Заглушка ставится ОДИН раз и читает изменяемый список: повторный вызов
// Http::fake() прежнюю заглушку НЕ заменяет — отвечает по-прежнему ПЕРВАЯ, и
// тест «а теперь ДаДата отвечает» молча проверял бы старый ответ (В-106).
$this->daDataByPhone = [];
$this->daDataProvider = 'МТС';
Http::fake(['cleaner.dadata.ru/*' => function ($request) {
$phone = (string) (($request->data()[0]) ?? '');
return Http::response([[
'qc' => 0,
'provider' => $this->daDataProvider,
'timezone' => $this->daDataByPhone[$phone] ?? null,
]], 200);
}]);
});
/** Момент UTC по человеческой записи. */
function snapRegionUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
/**
* Что ДаДата отвечает про каждый номер. Можно менять по ходу теста — заглушка
* читает этот список на каждый вопрос (см. ловушку В-106 в beforeEach).
*
* @param array<int|string, ?string> $byPhone номер → строка пояса ДаДаты («UTC+3»)
*
* Ключ int|string намеренно: PHP сам делает из ключа-номера целое число,
* поэтому обещание «только строка» было ложным.
*/
function snapRegionDaData(array $byPhone): void
{
test()->daDataByPhone = $byPhone;
}
/** Заказ рассылки по списку, вписанному руками. */
function snapRegionOrder(array $phones): int
{
return (int) test()->postJson('/api/sms/campaigns', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => $phones,
])->assertCreated()->json('id');
}
/** Сделка с регионом (или без него). 🪤 Регион живёт в subject_code, НЕ в region_code (В-82). */
function snapRegionDeal(int $tenantId, string $phone, ?int $subjectCode): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'subject_code' => $subjectCode,
'received_at' => now()->subDay(),
]));
}
/**
* Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги.
* 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при
* создании рассылки и читателем снимка при отправке (В-46).
*/
function snapRegionRealRoute(): void
{
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
$router,
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
));
}
function snapRegionEnrich(int $campaignId, int $tenantId): void
{
(new EnrichClientSmsSnapshotRegionJob($campaignId, $tenantId))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(SmsQuietHours::class),
app(OperatorNormalizer::class),
app(AllowedSmsOperators::class),
);
}
it('3.3 — вписанный руками номер не уходит молча: заказ ставит вопрос ДаДате', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
snapRegionDaData(['79995550001' => 'UTC+3']);
Queue::fake(); // смотрим на снимок ДО того, как что-либо отработало
$id = snapRegionOrder(['79995550001']);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
// Номер в снимке есть, причины пропуска у него нет, но и пояса нет — он ЖДЁТ.
expect($row->tz_offset_minutes)->toBeNull()
->and($row->skip_reason)->toBeNull()
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
// И вопрос ДаДате поставлен — без него номер ждал бы вечно.
Queue::assertPushed(EnrichClientSmsSnapshotRegionJob::class);
});
it('3.3 — узнав регион и оператора, номер уходит в своё утро', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
snapRegionDaData([
'79995550001' => 'UTC+3', // Москва — окно открыто прямо сейчас
'79995550002' => 'UTC+12', // Камчатка — у неё вечер, подождёт до утра
]);
$id = snapRegionOrder(['79995550001', '79995550002']);
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
// ДаДата сказала и регион, и оператора — второго обращения за оператором нет.
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
->and($rows['79995550001']->operator)->toBe('mts')
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
->and($rows['79995550002']->send_after)->not->toBeNull();
// Москвич ушёл сразу, камчатский честно ждёт своего утра.
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Наступило камчатское утро — добор доводит рассылку до конца сам.
$this->travelTo(snapRegionUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('контрольный: без ответа ДаДаты номер не уходит, но и не теряется', function () {
// Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать
// «уходит всегда, независимо от того, узнали мы регион или нет» (урок В-54).
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
// ДаДата отвечает, но пояса в ответе нет — как для номера, про который она не знает.
snapRegionDaData(['79995550001' => null]);
$id = snapRegionOrder(['79995550001']);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
expect($row->tz_offset_minutes)->toBeNull()
->and($row->skip_reason)->toBeNull() // не потерян и не помечен зря
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// И добор его тоже не трогает: ждать нечего, пока регион не узнан.
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
});
it('3.3 — про номер, у которого пояс уже есть, ДаДату не спрашиваем (это наши деньги)', function () {
// Решение В-105: платный вопрос задаём ТОЛЬКО там, где без ответа сообщение не
// уйдёт вовсе. Лишний вопрос по рассылке на 20 000 номеров — это тысячи рублей.
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550002' => 'UTC+12']);
Queue::fake();
$id = snapRegionOrder(['79995550001', '79995550002']);
// Первому пояс уже проставлен (например, прошлым прогоном обогащения).
ClientSmsCampaignPhone::where('campaign_id', $id)
->where('phone', '79995550001')
->update(['tz_offset_minutes' => 180]);
snapRegionEnrich($id, (int) $this->tenant->id);
Http::assertSentCount(1);
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->where('phone', '79995550002')
->value('tz_offset_minutes'))->toBe(720);
});
it('3.3 — не узнали за сутки: честная причина, и рассылка заканчивается', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => null]); // ДаДата про этот номер ничего не знает
$id = snapRegionOrder(['79995550001']);
// Сутки номер ждёт — молча, без причины: вдруг регион ещё узнается (В-89).
$this->travelTo(snapRegionUtc('2026-08-04 08:00')); // прошло 23 часа
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Сутки прошли — называем причину и заканчиваем рассылку.
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // прошло 25 часов
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$message = ClientSmsMessage::where('campaign_id', $id)->firstOrFail();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))
->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and($message->status)->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and((string) $message->cost_rub)->toBe('0.00')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('3.3 — узнанный за сутки регион побеждает: причину так и не поставили', function () {
// Парный к предыдущему (В-54): если бы «через сутки» ставилось всем подряд, этот
// прогон покраснел бы — номер узнал свой регион и обязан уйти, а не получить причину.
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => null]);
$id = snapRegionOrder(['79995550001']);
// Через час ДаДата уже отвечает — например, обновила справочник.
$this->travelTo(snapRegionUtc('2026-08-03 10:00'));
snapRegionDaData(['79995550001' => 'UTC+3']);
snapRegionEnrich($id, (int) $this->tenant->id);
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
->and(ClientSmsMessage::where('campaign_id', $id)->value('status'))
->not->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('3.3 — деньги: заморозка снимается, когда ждать больше нечего', function () {
// В песочнице деньги не двигаются вовсе, поэтому здесь настоящий режим: иначе
// «заморозка снялась» доказывалось бы тем, что её и не было (В-81).
config(['services.sms.sandbox' => false]);
snapRegionRealRoute();
Http::fake([
'cleaner.dadata.ru/*' => fn () => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => null]], 200),
'*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200),
]);
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'test');
snapRegionDeal((int) $this->tenant->id, '79995550001', 82); // Москва — уйдёт
snapRegionDeal((int) $this->tenant->id, '79995550003', null); // региона нет — подождёт
$id = (int) $this->postJson('/api/sms/campaigns', [
'title' => 'По сделкам',
'source' => 'deals',
'body' => 'Здравствуйте!',
'audience_days' => 30,
])->assertCreated()->json('id');
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->firstOrFail();
expect((string) $wallet->frozen_rub)->not->toBe('0.00'); // пока ждём — держим
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$wallet->refresh();
$campaign = ClientSmsCampaign::find($id);
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and((string) $wallet->frozen_rub)->toBe('0.00')
// Списано ровно за отправленное, а не за смету на обоих.
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00')
->and((string) $campaign->actual_cost_rub)->toBe(
bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2)
);
});
it('4.14 — оператор не из четвёрки, названный ДаДатой, выбывает из снимка', function () {
// 🔴 Вторая точка решения (строка листа 4.14, решение владельца В-149).
// Сырое имя оператора есть только в ответе ДаДаты: в снимок ложится
// канонический ключ, и «Тинькофф Мобайл» стал бы там неотличим от «ещё не
// спрашивали». Значит приговор выносится в момент ответа, другого момента нет.
//
// Тест ПАРНЫЙ (урок В-54): рядом стоит тот же прогон с оператором из четвёрки.
// Без него «выбыл» доказывало бы разве что «выбывают все подряд».
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
snapRegionDaData(['79995550001' => 'UTC+3', '79995550002' => 'UTC+3']);
Queue::fake(); // обогащение зовём руками, чтобы смотреть ровно на него
$allowed = snapRegionOrder(['79995550001']);
// 🪤 Текст у второй рассылки ДРУГОЙ: одинаковый текст в те же десять минут
// портал считает случайным повтором и отвечает 409 (защита из строки 2.6).
$denied = (int) $this->postJson('/api/sms/campaigns', [
'title' => 'Свой список — второй',
'source' => 'manual',
'body' => 'Добрый день!',
'phones' => ['79995550002'],
])->assertCreated()->json('id');
$this->daDataProvider = 'МТС';
snapRegionEnrich($allowed, (int) $this->tenant->id);
$this->daDataProvider = 'Тинькофф Мобайл';
snapRegionEnrich($denied, (int) $this->tenant->id);
$allowedRow = ClientSmsCampaignPhone::where('campaign_id', $allowed)->firstOrFail();
$deniedRow = ClientSmsCampaignPhone::where('campaign_id', $denied)->firstOrFail();
// Абонент четвёрки: узнал пояс и оператора, причины пропуска нет.
expect($allowedRow->skip_reason)->toBeNull()
->and($allowedRow->tz_offset_minutes)->toBe(180)
->and($allowedRow->operator)->toBe('mts');
// Мелкий оператор: честная причина, и время ему больше не нужно.
expect($deniedRow->skip_reason)->toBe(ClientSmsMessage::SKIP_OPERATOR_DENIED)
->and($deniedRow->tz_offset_minutes)->toBeNull();
});
it('3.3 — выбранный дневной лимит ДаДаты не помечает номера окончательно', function () {
// Лимит выбран — это не «регион неизвестен навсегда», а «спросим завтра».
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => 'UTC+3']);
config(['services.dadata.daily_cap_rub' => 1]);
app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽
Queue::fake();
$id = snapRegionOrder(['79995550001']);
snapRegionEnrich($id, (int) $this->tenant->id);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
Http::assertNothingSent();
expect($row->tz_offset_minutes)->toBeNull()
->and($row->skip_reason)->toBeNull();
});