1122c4ddc9
Строка листа 4.8. Портал теперь понимает «+79991234567», «8 999 000 00 02», «(999) 000-00-03» и «7999-000-00-04» — раньше первый же такой номер отбивался ошибкой 422. Вся строка оказалась одной лишней проверкой в правилах заказа: 'phones.*' => 'string|size:11' требовала ровно 11 знаков и срабатывала ДО разборщика номеров, который читает все эти виды прекрасно. Умение было — ему мешала проверка. Разбор номеров живёт в одном доме (normalizeManualPhones), куда ходят и предпросмотр, и заказ: второй такой дом означал бы, что смета и факт считаются по разным спискам. Непонятые номера не пропадают молча (строка листа так и требует — «возвращаются образцами»): сервер отдаёт их число и до двадцати образцов, а экран говорит «Не поняли 1 номер: абвгд — проверьте их и впишите заново. Остальные уйдут как обычно». Отдаём и в предпросмотре, и в ответе на заказ: между ними список могли дописать. Доказательства: 3 серверных теста и 2 фронтовых (второй парный — когда всё разобрано, экран молчит), 2 выреза, живой прогон в браузере. ClientSms 284/284, приём лидов 17/17, фронт 1687, phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
365 lines
16 KiB
PHP
365 lines
16 KiB
PHP
<?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);
|
||
|
||
$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);
|
||
});
|
||
|
||
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']);
|
||
});
|