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