b6c837e28a
Task 8, приёмка Этапа 4. Кода она не приносит по замыслу: все двенадцать строк листа (4.1–4.8, 4.11–4.14) закрыты ДВУМЯ доказательствами каждая — тест И живой прогон, — экранные строки отдельно посмотрены глазами в браузере. Итоговая таблица Этапа 4 заполнена в приёмочном листе (лист в git не лежит). В git уходит ровно одна правка — порядок импортов в шести файлах тестов (В-192). Причина, по которой она вообще нашлась: три этапа подряд `pint` гонялся ТОЛЬКО по свежим файлам, а по всей области модуля не гонялся ни разу. Заодно выяснено, что жалобы `line_ending` чинить не надо — это виндовые переводы строк рабочей копии, в git их нет вовсе (pint «исправил», git не увидел ни одного изменения). Перегнаны тесты этих шести файлов: 41/41 зелено. 🔴 ДЕНЕЖНАЯ МИНА СВЕРХ ПОСТРОЕННОГО (В-190). Права спросил у самой базы матрицей по всем таблицам модуля — и увидел, что у рабочей роли crm_app_user есть право писать в таблицу заморозок, а на её счётчик номеров ad_wallet_holds_id_seq права нет. Живой прогон парно: без права заморозка денег под боевой ролью ПАДАЕТ («нет доступа к последовательности»), с правом идёт. Заморозка делается при КАЖДОМ заказе рассылки, при заказе имени и при запуске рекламной кампании Яндекса. Промах был мой: в В-142 я привёл стенд к эталону двумя точечными командами по именам из плана вместо `ON ALL SEQUENCES`, как делает сам db/02_grants.sql. Памятка выката переписана: вместо списка имён счётчиков — запрос, который САМ находит все счётчики без права. На бою проверить (косвенно там всё в порядке — рекламные кампании запускаются тем же кодом, но довод косвенный). 🔴 И ПЕРВЫЙ ЗАХОД ПРИЁМКИ ПРОШЁЛ «ЗЕЛЕНО», НЕ ДОКАЗАВ НИ ОДНОЙ ДЕНЕЖНОЙ СТРОКИ (В-191): песочница гасит и возврат заморозки при срыве, и ночного работника целиком. Датчик на будущее — не сдвинулась ни одна копейка, значит прогон не доказал ничего, даже когда всё зелено. Ещё три промаха своих же приборов: В-193 и В-194 (прогон не доходил до состояния — сторож законно даёт попытке дожать её срок, а имя без отметки согласования кнопка законно не включает: правда была в коде), В-195 (браузерный замер читал таблицу рассылок вместо базы и «доказал» поломку, которой нет — класс В-121, соврал прибор). Живьём под боевой ролью crm_app_user: сторож зависших тремя заходами (пометка → выдержка срока → срыв, заморозка 17.00 → 0.00); имя за долг вернулось и списало ровно 100 ₽, отключённое владельцем осталось выключенным при 5 000 ₽; кнопка включения отказала числами, без пометки клиента 404, с пометкой списала 600 ₽; номера руками легли пятью видами записи; продолжение довело 5 из 5 за 42.50 ₽. Под служебной ролью — чистка снимка тройкой (без права падение, без политики srv_bypass «успешный ноль», с обоими удалено 3). Прогоны: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов 17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan ровно 2 чужие давние, vue-tsc ровно 5 чужих давних в 5 файлах (git blame: от 25.07), pint чисто. Стенд сверен со снимком «до» поле за полем и совпал. Намеренное изменение одно: рабочей роли выданы права на ВСЕ счётчики схемы, как в эталоне db/02_grants.sql. 🟡 Открытый вопрос владельцу — В-182: номера в журнале сообщений живут без срока, обязательство «90 дней» закрывает только снимок получателей. Ветка НЕ влита в main и НЕ выкачена. Порядок выката: миграции → db/03_service_bypass_policies.sql → подсчёт политик srv_bypass (+8) → права → контрольный запуск чистки снимка. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
11 KiB
PHP
221 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\SendClientSmsCampaignJob;
|
|
use App\Models\AdWallet;
|
|
use App\Models\ClientSmsCampaign;
|
|
use App\Models\ClientSmsCampaignPhone;
|
|
use App\Models\ClientSmsContact;
|
|
use App\Models\ClientSmsMessage;
|
|
use App\Models\ClientSmsOptout;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Advertising\AdWalletService;
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
|
use App\Services\ClientSms\ClientSmsPricing;
|
|
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
|
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
|
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\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
/**
|
|
* Строки листа 2.1–2.2: снимок получателей.
|
|
*
|
|
* Смысл: список получателей фиксируется ОДИН раз — в момент создания рассылки, —
|
|
* и после этого не пересматривается ничем (решение владельца В-39). Раньше
|
|
* аудитория собиралась дважды: в контроллере для сметы и заново в джобе для
|
|
* отправки. Между двумя сборками приходят новые лиды, и факт расходился со сметой.
|
|
*
|
|
* Синтетические номера 7999… — реальные НИКОГДА.
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['services.sms.sandbox' => true]);
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
|
|
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем —
|
|
// иначе тест зависел бы от часа, в который его запустили (вечером все номера
|
|
// ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто.
|
|
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
|
|
});
|
|
|
|
/** Заказ рассылки по своей базе. Порядок слагаемых: переданное побеждает (оператор «+»). */
|
|
function snapshotPayload(array $extra = []): array
|
|
{
|
|
return $extra + [
|
|
'title' => 'Снимок',
|
|
'source' => 'base',
|
|
'body' => 'Здравствуйте!',
|
|
];
|
|
}
|
|
|
|
function snapshotContact(int $tenantId, string $phone): ClientSmsContact
|
|
{
|
|
return ClientSmsContact::create([
|
|
'tenant_id' => $tenantId,
|
|
'phone' => $phone,
|
|
'name' => 'Кто-то',
|
|
'operator' => 'МТС',
|
|
// Этап 3: без известного региона номер не уходит вовсе (В-85).
|
|
'tz_offset_minutes' => 180,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Маршрут МТС существует — иначе в реальном режиме получателей ноль и тест пустой.
|
|
*
|
|
* 🪤 Подменять ОДИН отборщик (как в тестах Этапа 1) теперь мало: со снимком канал
|
|
* выбирается дважды — при создании рассылки отборщиком и при отправке читателем
|
|
* снимка. Читатель берёт роутер из контейнера, поэтому подменять надо именно
|
|
* роутер: иначе смета посчитается, а отправится ноль — и денежный тест окажется
|
|
* пустым, оставаясь зелёным.
|
|
*/
|
|
function snapshotRealRoute(): 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 snapshotRunJob(int $campaignId, int $tenantId): void
|
|
{
|
|
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
|
|
app(ClientSmsSnapshotReader::class),
|
|
app(ClientSmsPricing::class),
|
|
app(AdWalletService::class),
|
|
);
|
|
}
|
|
|
|
it('фиксирует получателей в момент создания рассылки', function () {
|
|
snapshotContact($this->tenant->id, '79990000001');
|
|
snapshotContact($this->tenant->id, '79990000002');
|
|
// Этот в стоп-листе — в снимке он тоже есть, но с причиной «не уйдёт».
|
|
snapshotContact($this->tenant->id, '79990000009');
|
|
ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000009']);
|
|
|
|
$response = $this->postJson('/api/sms/campaigns', snapshotPayload())->assertCreated();
|
|
$campaignId = (int) $response->json('id');
|
|
|
|
$snapshot = ClientSmsCampaignPhone::where('campaign_id', $campaignId)->get();
|
|
|
|
expect($snapshot)->toHaveCount(3)
|
|
->and($snapshot->whereNull('skip_reason')->pluck('phone')->sort()->values()->all())
|
|
->toBe(['79990000001', '79990000002'])
|
|
->and($snapshot->firstWhere('phone', '79990000009')->skip_reason)
|
|
->toBe(ClientSmsMessage::SKIP_OPTOUT);
|
|
});
|
|
|
|
it('не шлёт тому, кто появился уже ПОСЛЕ создания рассылки', function () {
|
|
snapshotContact($this->tenant->id, '79990000001');
|
|
|
|
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
|
|
->assertCreated()->json('id');
|
|
|
|
// Контакт приходит между созданием рассылки и её отправкой: в снимке его нет,
|
|
// значит и СМС ему не уйдёт — считали одно, отправляем ровно то же.
|
|
snapshotContact($this->tenant->id, '79990000777');
|
|
|
|
snapshotRunJob($campaignId, $this->tenant->id);
|
|
|
|
expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(1)
|
|
->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000777')->exists())
|
|
->toBeFalse();
|
|
});
|
|
|
|
it('шлёт тому, кого внесли в стоп-лист уже ПОСЛЕ запуска — решение владельца В-39', function () {
|
|
snapshotContact($this->tenant->id, '79990000001');
|
|
snapshotContact($this->tenant->id, '79990000002');
|
|
|
|
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
|
|
->assertCreated()->json('id');
|
|
|
|
// Клиент передумал уже после запуска. Владелец 27.07: «написать всё равно» —
|
|
// список зафиксирован, смета и факт обязаны сойтись копейка в копейку.
|
|
ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']);
|
|
|
|
snapshotRunJob($campaignId, $this->tenant->id);
|
|
|
|
expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(2)
|
|
->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000002')
|
|
->where('status', ClientSmsMessage::STATUS_FAKE_SENT)->exists())
|
|
->toBeTrue();
|
|
});
|
|
|
|
it('2.3 — канал исчез после снимка: в журнале «не определён оператор», как и в предпросмотре', function () {
|
|
// Номер без оператора: так приходят номера, вписанные руками, и номера «своей
|
|
// базы», которым ДаДата ещё не проставила оператора. В песочнице заглушка
|
|
// универсальная, поэтому в снимок он попадает как «уйдёт».
|
|
ClientSmsContact::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'phone' => '79990000123',
|
|
'name' => 'Без оператора',
|
|
'operator' => null,
|
|
// Регион известен, а оператор — нет: тест ровно про оператора. Без региона
|
|
// номер вообще не дошёл бы до вопроса о маршруте и просто ждал бы (Этап 3, В-85).
|
|
'tz_offset_minutes' => 180,
|
|
]);
|
|
|
|
// 🪤 Без этого джоб выполняется ВНУТРИ запроса на создание (В-61) — то есть ещё
|
|
// со старым, универсальным каналом: номер уходит, и мой прогон оказывается
|
|
// вторым, где он уже «обработан». Отправку выполняем сами, после подмены канала.
|
|
Queue::fake();
|
|
|
|
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
|
|
->assertCreated()->json('id');
|
|
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $campaignId)->whereNull('skip_reason')->count())
|
|
->toBe(1); // иначе тест проверял бы не то
|
|
|
|
// Универсальный канал выключили уже ПОСЛЕ создания рассылки — маршрута больше нет.
|
|
// Причину в журнале ставит читатель снимка, и она обязана совпадать с той, что
|
|
// человек видел в предпросмотре: «не определён оператор», а не «не из МТС» (В-70).
|
|
snapshotRealRoute();
|
|
snapshotRunJob($campaignId, $this->tenant->id);
|
|
|
|
expect(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000123')->value('status'))
|
|
->toBe(ClientSmsMessage::SKIP_UNKNOWN_OPERATOR);
|
|
});
|
|
|
|
it('списывает ровно смету — до копейки', function () {
|
|
config(['services.sms.sandbox' => false]);
|
|
snapshotRealRoute();
|
|
Http::fake(['*' => Http::response([
|
|
'code' => 0,
|
|
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
|
|
], 200)]);
|
|
|
|
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
|
|
snapshotContact($this->tenant->id, '79990000001');
|
|
snapshotContact($this->tenant->id, '79990000002');
|
|
|
|
$campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload())
|
|
->assertCreated()->json('id');
|
|
|
|
// Мир меняется между сметой и отправкой — на деньги это влиять не должно.
|
|
snapshotContact($this->tenant->id, '79990000778');
|
|
|
|
snapshotRunJob($campaignId, $this->tenant->id);
|
|
|
|
$campaign = ClientSmsCampaign::find($campaignId);
|
|
expect((string) $campaign->actual_cost_rub)->toBe((string) $campaign->estimated_cost_rub)
|
|
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой
|
|
|
|
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
|
expect((string) $wallet->balance_rub)->toBe(bcsub('1000.00', (string) $campaign->actual_cost_rub, 2))
|
|
->and((string) $wallet->frozen_rub)->toBe('0.00');
|
|
});
|