Files
portal/app/tests/Feature/ClientSms/AutoSmsLogTest.php
T
Дмитрий 4784382d49 feat(смс-клиент): журнал автоматических СМС за 30 дней на экране клиента
Строка листа 4.11, решение владельца В-102 «обязательно нужен». Причины по
авто-СМС писались в базу, но человеку их не показывал НИ ОДИН адрес портала:
у авто-СМС нет кампании, а единственный список сообщений отбирает их по
номеру кампании. Это и была суть задачи, а не мелочь на потом.

Что сделано:
· GET /api/sms/auto-log отбирает ровно авто-СМС (кампания пуста) за 30 дней:
  «ушло», «не ушло» и разбивка по причинам — АГРЕГАТАМИ по всему окну, из
  базы приезжают числа, а не тысячи строк;
· поимённый список — последние 100 событий, потолок жёсткий, параметра у
  клиента нет вовсе; если событий больше, экран честно говорит «Показаны
  последние 100 событий из N. Числа выше — за все 30 дней» (В-170, класс
  В-166: обязательство держится запретом на сервере, а не вежливостью экрана);
· блок на вкладке «Авто-СМС»: числа, причины человеческими словами по уже
  заведённому словарю подписей, таблица событий, а на пустом журнале —
  фраза «За 30 дней автоматических СМС не было» вместо пустой таблицы.

Попутно починены две неправды экрана — обе внутри обязательства «человеческими
словами», а не сверх него:
· подпись «отписались раньше» переписана на «номер в вашем стоп-листе»
  (В-169): возможности отказаться у получателя НЕТ вовсе (решение В-30),
  номер в этот список вносит сам клиент. Подпись пережила отмену целой
  функции и объясняла человеку то, чего в портале нет — класс В-154;
· пробное сообщение считается НЕ ушедшим (В-172). План велел обратное, но
  денежный счётчик модуля считает только настоящее «отправлено», и подпись
  экрана говорит «пробный режим — не отправлено». Это четырнадцатая ошибка
  плана, найденная тем же приёмом: открыть код и посмотреть, как это же
  состояние трактуют соседи.

Статанализ поймал то, чего не увидели ни четыре зелёных теста, ни живой
прогон, ни браузер (В-174): разбивка читала у модели поля, которых у неё нет.
Переписано на пару «причина → сколько». У каждого прибора своя слепая зона.

Проверено: ClientSms 309/309, приём лидов 17/17, фронт 1697 + 3 пропущенных,
phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Восемь вырезов,
каждый покраснел там, где вырезан. Живой прогон под боевой ролью crm_app_user,
парно: события писал НАСТОЯЩИЙ джоб авто-СМС — с пометкой клиента «ушло 1
(списано 9.00 ₽), не ушло 2: пробный режим 1, стоп-лист 1», без пометки тот же
вызов дал ноль. На 363 событиях — ровно 100 строк в таблице при полных числах.
ДаДата не тронута: клиент подменён заглушкой. Стенд возвращён и сверен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 06:45:25 +03:00

162 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
/**
* Журнал автоматических СМС на экране клиента (строка листа 4.11, решение
* владельца В-102 «обязательно нужен»).
*
* Авто-СМС не принадлежат ни одной рассылке (`campaign_id` пуст), поэтому их не
* показывает ни один существующий адрес портала — причина записана в базу, а
* человеку не видна. Здесь проверяется, что видна.
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-08-15 12:00:00');
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
afterEach(function () {
Carbon::setTestNow();
});
/** Авто-СМС: сообщение без кампании. */
function autoLogMessage(int $tenantId, string $phone, string $status, ?string $createdAt = null): ClientSmsMessage
{
$message = ClientSmsMessage::create([
'tenant_id' => $tenantId,
'campaign_id' => null,
'phone' => $phone,
'status' => $status,
'cost_rub' => $status === ClientSmsMessage::STATUS_SENT ? '8.50' : '0.00',
'segments' => 1,
]);
if ($createdAt !== null) {
// Через модель дата перезапишется текущим временем — правим запросом.
ClientSmsMessage::whereKey($message->id)->update(['created_at' => $createdAt]);
}
return $message;
}
it('журнал показывает только авто-СМС и только за последние 30 дней', function () {
$tenantId = (int) $this->tenant->id;
autoLogMessage($tenantId, '79990000001', ClientSmsMessage::STATUS_SENT);
autoLogMessage($tenantId, '79990000002', ClientSmsMessage::SKIP_OPTOUT);
autoLogMessage($tenantId, '79990000003', ClientSmsMessage::SKIP_NO_FUNDS);
// Сообщение РАССЫЛКИ в журнал авто-СМС попадать не должно: у него есть кампания.
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1,
'planned_count' => 1,
'total_sms' => 1,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '8.50',
'created_by' => null,
]);
ClientSmsMessage::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => '79990000004',
'status' => ClientSmsMessage::STATUS_SENT,
'cost_rub' => '8.50',
'segments' => 1,
]);
// Авто-СМС старше 30 дней — тоже не должно.
autoLogMessage($tenantId, '79990000005', ClientSmsMessage::STATUS_SENT, '2026-06-01 10:00:00');
$response = $this->getJson('/api/sms/auto-log');
$response->assertOk();
expect($response->json('totals.sent'))->toBe(1)
->and($response->json('totals.skipped'))->toBe(2)
->and(collect($response->json('reasons'))->pluck('count', 'reason')->all())
->toBe([ClientSmsMessage::SKIP_NO_FUNDS => 1, ClientSmsMessage::SKIP_OPTOUT => 1])
->and(collect($response->json('messages'))->pluck('phone')->all())
->toBe(['79990000003', '79990000002', '79990000001']);
});
it('пробное сообщение считается НЕ ушедшим (В-172)', function () {
$tenantId = (int) $this->tenant->id;
autoLogMessage($tenantId, '79990000011', ClientSmsMessage::STATUS_SENT);
autoLogMessage($tenantId, '79990000012', ClientSmsMessage::STATUS_FAKE_SENT);
autoLogMessage($tenantId, '79990000013', ClientSmsMessage::STATUS_FAILED);
$response = $this->getJson('/api/sms/auto-log');
$response->assertOk();
// Пробное не ушло абоненту, и денежный счётчик модуля его тоже не считает.
expect($response->json('totals.sent'))->toBe(1)
->and($response->json('totals.skipped'))->toBe(2)
->and(collect($response->json('reasons'))->pluck('count', 'reason')->all())
->toBe([
ClientSmsMessage::STATUS_FAILED => 1,
ClientSmsMessage::STATUS_FAKE_SENT => 1,
]);
});
it('журнал чужого клиента не показывает', function () {
$tenantId = (int) $this->tenant->id;
$stranger = Tenant::factory()->create();
autoLogMessage((int) $stranger->id, '79990000021', ClientSmsMessage::STATUS_SENT);
autoLogMessage((int) $stranger->id, '79990000022', ClientSmsMessage::SKIP_NO_FUNDS);
autoLogMessage($tenantId, '79990000023', ClientSmsMessage::STATUS_SENT);
$response = $this->getJson('/api/sms/auto-log');
$response->assertOk();
expect($response->json('totals.sent'))->toBe(1)
->and($response->json('totals.skipped'))->toBe(0)
->and(collect($response->json('messages'))->pluck('phone')->all())
->toBe(['79990000023']);
});
it('числа считаются по всем 30 дням, а список событий урезан сотней (В-170)', function () {
$tenantId = (int) $this->tenant->id;
for ($i = 1; $i <= 105; $i++) {
autoLogMessage($tenantId, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT), ClientSmsMessage::STATUS_SENT);
}
autoLogMessage($tenantId, '79990009999', ClientSmsMessage::SKIP_NO_FUNDS);
$response = $this->getJson('/api/sms/auto-log');
$response->assertOk();
// Числа — по ВСЕМ событиям окна, а не по показанной сотне.
expect($response->json('totals.sent'))->toBe(105)
->and($response->json('totals.skipped'))->toBe(1)
->and($response->json('messages'))->toHaveCount(100)
// Человеку надо сказать, что список урезан, иначе он решит, что видит всё (В-48).
->and($response->json('messages_limit'))->toBe(100)
->and($response->json('messages_truncated'))->toBeTrue();
});