Files
portal/app/tests/Feature/ClientSms/PreviewExplainTest.php
T
Дмитрий 5fd18c35b1 feat(смс-клиент): экран объясняет цену и говорит, во сколько рассылка начнётся
Этап 3 «Время и цена», Task 7. Строки листа 3.11, 3.12, 3.13, 3.15.

Клиент видел одну цифру — цену за штуку — и не понимал, откуда она взялась и почему
завтра будет другой. Теперь смета проговаривает всё вслух: сколько СМС ушло в этом
месяце, какая ступень была бы без этого заказа, какая выходит с ним, и что 1-го числа
счётчик обнулится. У первой рассылки месяца формулировка другая: сказать новому клиенту
«вы были на 9.00 ₽» — неправда, он ни на чём не был (В-115).

Отдельно — предупреждение до нажатия кнопки (решение владельца В-86): «Сейчас 21:30 по
Москве — рассылка начнётся завтра в 01:00 по Москве». Оба часа приходят С СЕРВЕРА: на
компьютере клиента может стоять что угодно, а решение об окне принимает сервер. И всегда
сказано, ЧЬЁ это время (В-117): окно 10–20 у получателя местное, и камчатское утро —
это час ночи в Москве. Берётся самое раннее утро среди получателей.

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

Правило часов осталось в одном доме (SmsQuietHours::earliestOpening), формула цены — в
своём (ClientSmsPricing::explainFor, счётчик месяца спрашивается один раз на все три
числа). Экран по-прежнему ничего не считает сам — ни денег, ни часов.

🔴 Две неправды, которых не видел ни один тест.
· Самопроверка diff'а: цена за СМС стояла на экране дважды (В-119). Разойтись эти цифры не
  могли, но человек читает две одинаковые цены рядом как две разные. Оставил одну — в
  смете, там, где рядом объяснено, откуда она взялась.
· Живой прогон с ДЛИННЫМ текстом вскрыл давнюю, с Этапа 1, нестыковку (В-120): смета
  писала «Уйдёт 43 СМС — 1462.00 ₽», хотя 43 — это НОМЕРА, а СМС выходило 172. Цифра и
  сумма в одной строке не сходились между собой, и ни один тест этого не ловил: все брали
  короткий текст, где числа случайно совпадают. Теперь «Уйдёт на 43 номера — это 172 СМС,
  1462.00 ₽», число СМС считает сервер. Починено и в окне подтверждения.

Прогоны: СМС 239/239 (пачками по 3–4 файла, В-112), приём лидов 17/17, фронт 1674 зелёных,
phpstan 0 своих, pint чисто, проверка типов — 8 чужих давних.

Вырезами проверено пять раз: убрал ступень «без заказа» из ответа — покраснел тест 3.12;
убрал «окно открыто — значит сразу» — покраснел контрольный «днём начала нет»; взял первое
утро вместо самого раннего — покраснел камчатский тест; сделал фразу всегда «вы были» —
покраснел тест первой рассылки месяца; показал сноску всегда — покраснел контрольный.

Живьём (21:30 МСК, песочница, локальная база): три номера — Москва, Камчатка и один без
региона, 99 отправленных СМС в журнале. Экран показал разом все шесть строк. Парные
проверки: расширил окно до 23:00 — фраза про час исчезла; обнулил накопленное — цена
вернулась к 9.00, фразы про удешевление и сноски не стало; заказ на 172 СМС без
накопленного — вторая формулировка про удешевление. Стенд возвращён: окно 10–20,
0 контактов, 0 сообщений, 0 рассылок, 0 снимков.

🪤 Урок про стенд: тестовую базу чинить ТОЛЬКО `migrate:fresh` с чистого листа. `db:wipe
--drop-types` + ручной `migrate` оставляют её полумёртвой — чужая миграция чата ломается
на недочищенной базе, и дальше все прогоны врут «таблицы не существует».
2026-07-29 06:19:16 +03:00

185 lines
8.7 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);
});