Files
portal/app/tests/Feature/ClientSms/PreviewExplainTest.php
T

206 lines
9.9 KiB
PHP
Raw Normal View History

<?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');
});