5fd18c35b1
Этап 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` оставляют её полумёртвой — чужая миграция чата ломается на недочищенной базе, и дальше все прогоны врут «таблицы не существует».
185 lines
8.7 KiB
PHP
185 lines
8.7 KiB
PHP
<?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);
|
||
});
|