Files
portal/app/tests/Feature/ClientSms/EndpointTest.php
T
Дмитрий 9fc2f030cf feat(смс-клиент): срок «за последние N дней» не больше года
Строка приёмочного листа 2.8. Больше 365 дней задать нельзя, и человек читает
почему: «Больше 365 дней нельзя — возьмите срок покороче».

Правило max:365 стоит в общей проверке полей — значит и на предпросмотре, и на
создании рассылки, мимо экрана его не обойти. Заодно человеческий текст у нижней
границы: стандартный называл поле программистским именем audience_days.

На экране проверка настоящая, а не только атрибут max у поля: напечатать 400
руками браузер спокойно даёт, и атрибут при этом молчит. При запрещённом сроке
экран показывает сообщение, запрос на сервер не уходит и смета гасится — иначе
кнопка «Отправить» осталась бы живой со старым числом получателей.

Тесты: 2 новых серверных (отказ с человеческим текстом на обеих дорогах +
контрольная граница ровно 365) и 2 на экране. ClientSms 158/158, приём лидов
17/17, фронт 1654 зелёных. Проверено вырезанием четырежды.

Живой прогон: 30 дней — «Уйдёт 5 СМС — 45.00 ₽»; напечатал 400 — сообщение,
смета исчезла, кнопка заперлась, на сервер не ушло ни одного запроса; вернул
365 — смета снова на месте. Прямыми запросами мимо экрана сервер отказывает тем
же текстом и рассылку не создаёт.
2026-07-28 08:42:14 +03:00

303 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\EnrichClientSmsContactsOperatorJob;
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsTemplate;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
/**
* HTTP API клиентской СМС-рассылки (Task 7) — тонкий слой над готовыми сервисами
* ClientSmsAudienceBuilder/ClientSmsRecipientSelector/ClientSmsPricing и джобом
* SendClientSmsCampaignJob. Auth/tenant setup зеркалит AdvertisingCampaignEndpointTest:
* Tenant::factory + User::factory(tenant_id) + actingAs; middleware ['auth:sanctum','tenant'].
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Селектор с настоящим МТС-роутером — маршрутизирует mts даже в реальном режиме. */
function realMtsSelectorInstance(): ClientSmsRecipientSelector
{
return new ClientSmsRecipientSelector(
new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]),
new OperatorNormalizer,
);
}
it('preview (source=base) считает смету и НЕ создаёт кампанию', function () {
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']);
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']);
$response = $this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'base',
]);
$response->assertOk()
->assertJsonPath('segments', 1)
->assertJsonPath('sendable_count', 2);
expect($response->json('estimated_cost_rub'))->toBeString()
->and($response->json('price_rub_per_sms'))->toBeString();
expect(ClientSmsCampaign::count())->toBe(0);
});
it('uploadContacts сообщает, сколько номеров не распознали', function () {
$res = $this->postJson('/api/sms/contacts', [
'phones' => ['79990000001', 'мусор', '8 (999) 000-00-02', 'абвгд'],
]);
$res->assertOk()
->assertJsonPath('added', 2) // 79990000001 + нормализованный 8(999)000-00-02
->assertJsonPath('rejected', 2); // «мусор», «абвгд»
});
it('uploadContacts возвращает примеры отброшенных номеров (не молча)', function () {
$res = $this->postJson('/api/sms/contacts', [
'phones' => ['79990000001', 'мусор', 'абвгд'],
]);
$res->assertOk()
->assertJsonPath('added', 1)
->assertJsonPath('rejected', 2);
expect($res->json('rejected_samples'))->toBeArray()
->toContain('мусор')
->toContain('абвгд');
});
it('uploadContacts запускает обогащение оператором через ДаДату', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk();
Bus::assertDispatched(
EnrichClientSmsContactsOperatorJob::class,
fn ($job) => (fn () => $this->tenantId)->call($job) === $this->tenant->id,
);
});
it('index возвращает баланс и заморозку кошелька (для заслонки «Отправить»)', function () {
app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test');
$this->getJson('/api/sms/campaigns')
->assertOk()
->assertJsonPath('balance_rub', '100.00')
->assertJsonPath('frozen_rub', '0.00');
});
it('index без кошелька отдаёт нулевой баланс', function () {
$this->getJson('/api/sms/campaigns')
->assertOk()
->assertJsonPath('balance_rub', '0.00')
->assertJsonPath('frozen_rub', '0.00');
});
it('source=deals без срока (audience_days) отклоняется — не рассылаем по всей истории', function () {
// preview
$this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
])->assertStatus(422)->assertJsonValidationErrors('audience_days');
// store
$this->postJson('/api/sms/campaigns', [
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'source' => 'deals',
])->assertStatus(422)->assertJsonValidationErrors('audience_days');
expect(ClientSmsCampaign::count())->toBe(0);
});
it('source=deals со сроком проходит валидацию', function () {
$this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 30,
])->assertOk();
});
it('2.8 — срок больше года не принимается, и человек читает почему', function () {
// Предпросмотр
$this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 400,
])->assertStatus(422)
->assertJsonPath('errors.audience_days.0', 'Больше 365 дней нельзя — возьмите срок покороче.');
// И создание рассылки — иначе ограничение обходится в один запрос мимо экрана.
$this->postJson('/api/sms/campaigns', [
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 400,
])->assertStatus(422)
->assertJsonPath('errors.audience_days.0', 'Больше 365 дней нельзя — возьмите срок покороче.');
expect(ClientSmsCampaign::count())->toBe(0);
});
it('2.8 — ровно 365 дней принимается: границу не съели', function () {
// Контрольная проверка к предыдущему тесту: «не прошло» что-то доказывает
// только рядом с «прошло» (В-54). Заодно ловит ошибку на единицу.
$this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 365,
])->assertOk();
});
it('store (sandbox) создаёт кампанию queued и ставит джоб в очередь; заморозки нет', function () {
config(['services.sms.sandbox' => true]);
Queue::fake();
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']);
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']);
$response = $this->postJson('/api/sms/campaigns', [
'title' => 'Рассылка А',
'body' => 'Здравствуйте!',
'source' => 'base',
]);
$response->assertCreated()
->assertJsonPath('status', ClientSmsCampaign::STATUS_QUEUED);
$id = $response->json('id');
$this->assertDatabaseHas('client_sms_campaigns', [
'id' => $id,
'tenant_id' => $this->tenant->id,
'status' => ClientSmsCampaign::STATUS_QUEUED,
]);
Queue::assertPushed(SendClientSmsCampaignJob::class);
// Sandbox: заморозки нет — кошелёк не создан.
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('store (реальный режим, денег не хватает) → 409 и кампания откачена', function () {
config(['services.sms.sandbox' => false]);
Queue::fake();
// Роутер реального режима сам по себе mts не поднимет (нет токена) — подменяем
// селектор на реальный МТС, чтобы получатели прошли и смета была > 0.
$this->app->instance(ClientSmsRecipientSelector::class, realMtsSelectorInstance());
// Кошелёк есть, но денег мало (смета заведомо больше).
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']);
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']);
$response = $this->postJson('/api/sms/campaigns', [
'title' => 'Рассылка без денег',
'body' => 'Здравствуйте!',
'source' => 'base',
]);
$response->assertStatus(409);
// Кампания откачена — сироты нет.
expect(ClientSmsCampaign::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('изоляция тенантов: чужие кампании не видны и show чужой → 404', function () {
$mine = ClientSmsCampaign::create([
'tenant_id' => $this->tenant->id,
'title' => 'Моя', 'body' => 'x', 'sender_name' => 'liderra.ru',
'source' => 'base', 'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1, 'planned_count' => 0, 'total_sms' => 0,
'price_rub_per_sms' => '9.00', 'estimated_cost_rub' => '0.00',
]);
$tenantB = Tenant::factory()->create();
$foreign = ClientSmsCampaign::create([
'tenant_id' => $tenantB->id,
'title' => 'Чужая', 'body' => 'x', 'sender_name' => 'liderra.ru',
'source' => 'base', 'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1, 'planned_count' => 0, 'total_sms' => 0,
'price_rub_per_sms' => '9.00', 'estimated_cost_rub' => '0.00',
]);
$index = $this->getJson('/api/sms/campaigns');
$index->assertOk();
$ids = collect($index->json('campaigns'))->pluck('id')->all();
expect($ids)->toContain($mine->id)
->and($ids)->not->toContain($foreign->id);
$this->getJson("/api/sms/campaigns/{$foreign->id}")->assertStatus(404);
});
it('контакты: загрузка, список только своих, удаление, изоляция тенанта', function () {
$upload = $this->postJson('/api/sms/contacts', [
'phones' => ['79990000001', '89990000002', 'мусор'],
]);
$upload->assertOk();
// 2 валидных (мусор пропущен).
expect($upload->json('added'))->toBe(2);
$list = $this->getJson('/api/sms/contacts');
$list->assertOk();
expect($list->json())->toHaveCount(2);
// Чужой тенант не видит наши контакты.
$tenantB = Tenant::factory()->create();
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
$this->actingAs($userB);
expect($this->getJson('/api/sms/contacts')->json())->toHaveCount(0);
// Возвращаемся к своему и удаляем один.
$this->actingAs($this->user);
$contactId = ClientSmsContact::where('tenant_id', $this->tenant->id)->first()->id;
$this->deleteJson("/api/sms/contacts/{$contactId}")->assertOk();
expect($this->getJson('/api/sms/contacts')->json())->toHaveCount(1);
});
it('шаблоны: CRUD со скоупом тенанта', function () {
$create = $this->postJson('/api/sms/templates', ['title' => 'Привет', 'body' => 'Здравствуйте!']);
$create->assertCreated();
$tplId = $create->json('id');
$this->getJson('/api/sms/templates')->assertOk()->assertJsonCount(1);
$this->patchJson("/api/sms/templates/{$tplId}", ['title' => 'Обновлён'])
->assertOk()->assertJsonPath('title', 'Обновлён');
// Чужой тенант не видит/не трогает.
$tenantB = Tenant::factory()->create();
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
$this->actingAs($userB);
$this->getJson('/api/sms/templates')->assertOk()->assertJsonCount(0);
$this->patchJson("/api/sms/templates/{$tplId}", ['title' => 'Взлом'])->assertStatus(404);
$this->actingAs($this->user);
$this->deleteJson("/api/sms/templates/{$tplId}")->assertOk();
expect(ClientSmsTemplate::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});