Files
portal/app/tests/Feature/ClientSms/EndpointTest.php
T
Дмитрий 1d292082ab feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.

Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.

Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.

Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.

Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.

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

367 lines
16 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\ClientSmsCampaignPhone;
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\AllowedSmsOperators;
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,
new AllowedSmsOperators(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);
// 🪤 Ответ — объект `{items, max_upload_phones}`, а не голый массив: экрану
// нужен потолок загрузки ДО того, как человек нажмёт «Загрузить» (строка 4.6).
$list = $this->getJson('/api/sms/contacts');
$list->assertOk();
expect($list->json('items'))->toHaveCount(2);
// Чужой тенант не видит наши контакты.
$tenantB = Tenant::factory()->create();
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
$this->actingAs($userB);
expect($this->getJson('/api/sms/contacts')->json('items'))->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('items'))->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);
});
it('4.8 — номер принимается в любом виде: +7, 8, пробелы, скобки, дефисы', function () {
// 🪤 Вся строка 4.8 — это одна лишняя проверка в правилах заказа
// (`'phones.*' => 'string|size:11'`): она требовала РОВНО 11 знаков и отбивала
// «+79991234567» ошибкой 422 ещё до разборщика номеров, который читает такое
// прекрасно. Умение было — ему мешала проверка.
$response = $this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'manual',
'phones' => [
'+79990000001',
'8 999 000 00 02',
'(999) 000-00-03',
'7999-000-00-04',
],
]);
$response->assertOk()
->assertJsonPath('sendable_count', 4)
->assertJsonPath('rejected_count', 0);
});
it('4.8 — непонятые номера не пропадают молча: человек видит их образцами', function () {
// Строка листа так и говорит: «непонятые возвращаются образцами». Молчаливая
// пропажа хуже отказа — человек считает, что рассылка ушла на весь список.
$response = $this->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'manual',
'phones' => ['+79990000005', 'абвгд', '123'],
]);
$response->assertOk()
->assertJsonPath('sendable_count', 1)
->assertJsonPath('rejected_count', 2)
->assertJsonPath('rejected_samples', ['абвгд', '123']);
});
it('4.8 — при заказе рассылки номера тоже принимаются в любом виде', function () {
// Пара к предпросмотру: правило проверки ОДНО на оба адреса, но заказ ещё и
// пишет снимок — там номер обязан лежать уже в едином виде «79…».
app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'тест');
Queue::fake();
$response = $this->postJson('/api/sms/campaigns', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => ['+79990000001', '8 999 000 00 02', 'мусор'],
]);
$response->assertCreated()
->assertJsonPath('planned_count', 2)
->assertJsonPath('rejected_count', 1)
->assertJsonPath('rejected_samples', ['мусор']);
expect(ClientSmsCampaignPhone::where('campaign_id', $response->json('id'))
->orderBy('phone')->pluck('phone')->all())
->toBe(['79990000001', '79990000002']);
});