2026-07-29 06:19:16 +03:00
|
|
|
|
<?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);
|
|
|
|
|
|
});
|
2026-07-29 07:37:01 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Отказ при слишком длинном тексте написан по-человечески (журнал В-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');
|
|
|
|
|
|
});
|