Files
portal/app/tests/Feature/ClientSms/PreviewExplainTest.php
T
Дмитрий 6704054a80 fix(смс-клиент): приёмка Этапа 3 — служебная роль получила права, экран перестал врать
Этап 3 «Время и цена», Task 8 — приёмка. Строка листа Н.2 (проверка разграничения
доступа), итоговая таблица этапа заполнена.

🔴 Блокер выката, найден проверяющим по доступу и подтверждён лично.
Команда добора `client-sms:resume-waiting` (появилась в этом этапе, стоит в расписании
каждые 15 минут) ходит в базу под служебной ролью `crm_supplier_worker`: она обходит
рассылки ВСЕХ клиентов и потому не может работать под клиентской ролью. Прав этой роли
на три таблицы, которые она читает и правит, выдано не было — миграции модуля выдавали
права рабочей роли, а служебной только на имя отправителя и правило авто-СМС.

Локально этого не видно: dev и тесты ходят суперпользователем. На бою команда падала бы
с «нет доступа» каждые 15 минут, и камчатская часть рассылок не дошлалась бы НИКОГДА.
Тот же класс, что блокер прав на счётчики 27.07.

Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись
схемы v9.15. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, а
обещание было неполным — теперь спрашивает и про эти три таблицы.

Две неправды на экране, найденные живым прогоном на НЕудобных данных.
· Текст в 2325 символов: сервер отказал (потолок 1000), сметы нет, а строчка под текстом
  пишет «Умещается в 1 СМС за номер». В коде стояло `preview?.segments ?? 1` — на месте
  «не знаю» подставлялась единица и утверждалась как факт. Теперь без ответа сервера
  число СМС не называется вовсе: молчание честнее. Дыра не этого этапа (код от 26.07).
· Сам отказ был написан по-программистски: «Количество символов в поле body не может
  превышать 1000». Стало: «Текст длиннее 1000 символов — сократите его.»

Вырезами проверено четыре раза: опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки
не даёт и права молча не выдаёт) — сторож краснеет; опечатка в цели GRANT — краснеет;
убрал человеческий текст отказа — краснеет; вернул подстановку единицы — краснеет.
Всё возвращено.

Живой прогон строк 3.1–3.15 подряд (29.07, 06:45–07:00 МСК, песочница, локальная база):
камчатский номер ушёл сразу, московский стал ждать 10:00, без региона — ждёт уточнения;
экран сказал «Отправлено 1 из 3, 1 ждут утра в своих регионах, ещё 1 — уточняем регион»;
команда добора до утра дала 0, после наступления срока 1 и номер ушёл, повторный запуск
снова 0; ДаДата (ЗАГЛУШКА, боевой ключ не трогали) спрошена ровно один раз — про тот
номер, у которого пояса не было; авто-СМС на московский лид отложена ровно на 10:00, при
открытом окне три запуска дали одно сообщение; цена прошла 9.00 → 8.50 → 8.00 по
накоплению, у отправленной рассылки осталась 9.00; счётчик сложил 500 из рассылки и 500
из авто-СМС; песочные отправки в счётчик не пошли. Стенд возвращён ровно в исходное:
окно 10–20, 0 контактов, 0 сообщений, 0 рассылок, 0 снимков, 5 демо-сделок, кошелёк
1000.00 / заморожено 0.00.

Прогоны: СМС 240/240 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676
зелёных + 3 пропущенных, phpstan 2 чужие давние, vue-tsc 8 чужих давних (проверено
git blame), pint чисто.

🪤 Урок приёмки: мой собственный скрипт прогона отрапортовал «красных пачек нет» и НОЛЬ
зелёных — разбирал ответ не в том формате. Ноль почти всегда сбой, а не правда о мире.
Теперь скрипт считает зелёным только ответ, где прошедших БОЛЬШЕ НУЛЯ.

🔴 При выкате ветки порядок обязателен: миграции → db/03_service_bypass_policies.sql →
контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Эта миграция его НЕ
заменяет: без srv_bypass команда добора увидит тихий ноль даже с выданными правами.
2026-07-29 07:37:01 +03:00

206 lines
9.9 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\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строки листа 3.11–3.13 и 3.15: предпросмотр объясняет ЦЕНУ и ВРЕМЯ.
*
* Экран не считает ничего сам (ни денег, ни часов): все числа приходят с сервера
* готовыми. Часы — тем более: на компьютере клиента может стоять что угодно, а
* решение об окне принимает сервер (решение В-117).
*
* Помощники с префиксом explain* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Синтетические номера 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);
});
/** Уже отправленные в этом месяце СМС (одна строка журнала на $segments штук). */
function explainSent(int $tenantId, int $segments): void
{
ClientSmsMessage::create([
'tenant_id' => $tenantId,
'phone' => '79990000001',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => $segments,
'cost_rub' => '0.00',
]);
}
/** Номер своей базы с известным поясом (минуты от UTC) или без него. */
function explainContact(int $tenantId, string $phone, ?int $tz): void
{
ClientSmsContact::create([
'tenant_id' => $tenantId,
'phone' => $phone,
'operator' => 'mts',
'tz_offset_minutes' => $tz,
]);
}
/** Предпросмотр по своей базе — тем же запросом, каким его зовёт экран. */
function explainPreview(object $test, string $body = 'Здравствуйте!'): array
{
return $test->postJson('/api/sms/preview', [
'body' => $body,
'source' => 'base',
])->assertOk()->json();
}
/*
|--------------------------------------------------------------------------
| 3.11 / 3.12 — цена
|--------------------------------------------------------------------------
*/
it('3.11 — предпросмотр отдаёт, сколько СМС уже отправлено в этом месяце', function () {
explainSent((int) $this->tenant->id, 1240);
explainContact((int) $this->tenant->id, '79995550001', 180);
expect(explainPreview($this)['month_sent_count'])->toBe(1240);
});
it('3.11 — контрольный: у нового клиента в этом месяце ноль', function () {
// Без этого прогона предыдущий ничего не доказывает: поле могло бы всегда
// отдавать одно и то же число (урок В-54).
explainContact((int) $this->tenant->id, '79995550001', 180);
expect(explainPreview($this)['month_sent_count'])->toBe(0);
});
it('3.12 — предпросмотр отдаёт ступень БЕЗ этого заказа и ступень С ним', function () {
// Отправлено 99 — ступень 9.00. Этот заказ добавляет сотое СМС, и ступень
// становится 8.50: ровно тот случай, о котором экран обязан сказать вслух.
explainSent((int) $this->tenant->id, 99);
explainContact((int) $this->tenant->id, '79995550001', 180);
$preview = explainPreview($this);
expect($preview['price_rub_per_sms_before'])->toBe('9.00')
->and($preview['price_rub_per_sms'])->toBe('8.50');
});
it('3.12 — контрольный: без удешевления обе ступени одинаковы', function () {
explainSent((int) $this->tenant->id, 10);
explainContact((int) $this->tenant->id, '79995550001', 180);
$preview = explainPreview($this);
expect($preview['price_rub_per_sms_before'])->toBe('9.00')
->and($preview['price_rub_per_sms'])->toBe('9.00');
});
it('3.11 — предпросмотр отдаёт, сколько выйдет СМС, а не только сколько номеров (В-120)', function () {
// Длинный текст — это несколько СМС на номер, и платит клиент за КАЖДОЕ.
// Экран обязан назвать оба числа: 2 номера и 6 СМС. Пока сервер отдавал
// только номера, смета говорила «уйдёт 2 СМС» и брала деньги за шесть.
explainContact((int) $this->tenant->id, '79995550001', 180);
explainContact((int) $this->tenant->id, '79995550002', 180);
$long = str_repeat('Здравствуйте, это довольно длинное сообщение. ', 4);
$preview = explainPreview($this, $long);
expect($preview['segments'])->toBe(3)
->and($preview['sendable_count'])->toBe(2)
->and($preview['total_sms'])->toBe(6)
->and($preview['estimated_cost_rub'])->toBe('54.00'); // 6 СМС × 9.00
});
/*
|--------------------------------------------------------------------------
| 3.15 — когда рассылка начнётся (решение владельца В-86)
|--------------------------------------------------------------------------
*/
it('3.15 — вечером предпросмотр говорит, когда рассылка начнётся', function () {
// 21:30 по Москве: окно 10–20 у москвича закрыто, откроется завтра в 10:00
// местного времени, то есть 29.07 07:00 UTC.
$this->travelTo(CarbonImmutable::parse('2026-07-28T18:30:00', 'UTC'));
explainContact((int) $this->tenant->id, '79995550001', 180);
$preview = explainPreview($this);
expect($preview['starts_at'])->toBe('2026-07-29T07:00:00+00:00')
->and($preview['server_now'])->toBe('2026-07-28T18:30:00+00:00');
});
it('3.15 — контрольный: днём момент начала пуст (рассылка идёт сразу)', function () {
// Тот же номер, всё то же самое — кроме часа. Без этого прогона предыдущий
// не доказывает ничего: поле могло бы заполняться всегда (урок В-54).
$this->travelTo(CarbonImmutable::parse('2026-07-28T09:30:00', 'UTC'));
explainContact((int) $this->tenant->id, '79995550001', 180);
expect(explainPreview($this)['starts_at'])->toBeNull();
});
it('3.15 — берётся САМОЕ РАННЕЕ утро, а не первое попавшееся', function () {
// 28.07 18:30 UTC. У москвича (+3) утро наступит 29.07 в 07:00 UTC,
// у камчадала (+12) — уже сегодня в 22:00 UTC (это 10 утра 29-го у него).
// Рассылка начнётся с камчадала, и клиенту в Москве это будет час ночи —
// ровно то, ради чего момент отдаётся с сервера, а не выдумывается экраном (В-117).
$this->travelTo(CarbonImmutable::parse('2026-07-28T18:30:00', 'UTC'));
explainContact((int) $this->tenant->id, '79995550001', 180);
explainContact((int) $this->tenant->id, '79995550002', 720);
expect(explainPreview($this)['starts_at'])->toBe('2026-07-28T22:00:00+00:00');
});
it('3.15 — про номера без региона экран предупреждает отдельно (В-116)', function () {
// Момента начала у них нет вовсе: мы не знаем, когда узнаем регион. Молчать
// про них нельзя — человек ждал бы отправки, которой сегодня не будет.
$this->travelTo(CarbonImmutable::parse('2026-07-28T18:30:00', 'UTC'));
explainContact((int) $this->tenant->id, '79995550001', null);
$preview = explainPreview($this);
expect($preview['waiting_region_count'])->toBe(1)
->and($preview['starts_at'])->toBeNull();
});
it('3.15 — контрольный: у номера с регионом ожидания региона нет', function () {
$this->travelTo(CarbonImmutable::parse('2026-07-28T09:30:00', 'UTC'));
explainContact((int) $this->tenant->id, '79995550001', 180);
expect(explainPreview($this)['waiting_region_count'])->toBe(0);
});
/**
* Отказ при слишком длинном тексте написан по-человечески (журнал В-124).
*
* Найдено живым прогоном приёмки Этапа 3 на НЕудобных данных: текст в 2325 символов,
* и экран показал «Количество символов в поле body не может превышать 1000». Человек
* не знает, что такое «поле body», и не понимает, что делать. Рядом в этом же методе
* уже живут человеческие тексты для срока выборки — теперь и для длины текста.
*/
it('В-124 — про слишком длинный текст говорят человеческими словами', function () {
$response = $this->postJson('/api/sms/preview', [
'body' => str_repeat('а', 1001),
'source' => 'base',
]);
$response->assertStatus(422);
$message = $response->json('errors.body.0');
expect($message)->not->toContain('body')
->and($message)->toContain('1000');
});