Files
portal/app/tests/Feature/ClientSms/EndpointTest.php
T
Дмитрий d1a4c28dc1 feat(смс-клиент): API рассылок/базы/шаблонов + админ-тарифы + маршруты
ClientSmsController (index/preview/store/show, база контактов, шаблоны;
tenant по $request->user()->tenant_id + явный where; 409 при нехватке денег,
freeze при создании). AdminSmsTariffController (правка ступеней и настроек,
зона saas-admin/admin-db, crm_admin_user). Маршруты /api/sms и /api/admin/sms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:57 +03:00

194 lines
8.3 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\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\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('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);
});