65e270583c
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или виртуального оператора в рассылку не берётся, и человек видит честную причину, а не молчаливую пропажу. Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём. Список — настройкой, а не в коде: client_sms_settings.allowed_operators (схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию. Правило живёт в одном месте — AllowedSmsOperators. Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок и своей базы оператор известен в момент заказа, у номеров, вписанных руками, его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали». Плата за имя не тронута (В-150): в коде два похожих списка операторов, и связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей. Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём только по МТС)» — универсальный канал возит всех. Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка 24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн». ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
6.5 KiB
PHP
149 lines
6.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\AdWallet;
|
||
use App\Models\ClientSmsCampaign;
|
||
use App\Models\ClientSmsContact;
|
||
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\Queue;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Строки листа 1.18–1.20: двойное нажатие «Отправить».
|
||
*
|
||
* Две разные защиты, и путать их нельзя:
|
||
* — ЖЁСТКАЯ (ключ заказа): один и тот же запрос, повторённый браузером или самим
|
||
* клиентом по двойному клику, создаёт ОДНУ рассылку. Молча, без вопросов.
|
||
* — МЯГКАЯ (похожая рассылка за 10 минут): другой запрос, но подозрительно похожий —
|
||
* спрашиваем человека и слушаемся ответа.
|
||
*
|
||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
config(['services.sms.sandbox' => true]);
|
||
Queue::fake(); // рассылку никуда не отправляем — проверяем только приём заказа
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
});
|
||
|
||
/**
|
||
* Заказ рассылки. Порядок слагаемых важен: у оператора `+` побеждает ЛЕВАЯ часть,
|
||
* поэтому переданное вызывающим идёт первым, иначе подмена поля молча не сработает.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
function idemPayload(array $extra = []): array
|
||
{
|
||
return $extra + [
|
||
'title' => 'Акция',
|
||
'source' => 'manual',
|
||
'phones' => ['79990000001'],
|
||
'body' => 'Здравствуйте!',
|
||
];
|
||
}
|
||
|
||
it('два запроса с одним ключом заказа создают ОДНУ рассылку', function () {
|
||
$key = (string) Str::uuid();
|
||
|
||
$first = $this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => $key]))
|
||
->assertCreated();
|
||
|
||
$second = $this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => $key]))
|
||
->assertOk(); // 200, а не 201 — ничего нового не создано
|
||
|
||
expect($second->json('id'))->toBe($first->json('id'))
|
||
->and(ClientSmsCampaign::where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||
});
|
||
|
||
it('повтор с тем же ключом не замораживает деньги второй раз', function () {
|
||
config(['services.sms.sandbox' => false]);
|
||
|
||
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
|
||
|
||
// В реальном режиме нужен и номер с известным оператором, и сам маршрут: у
|
||
// тестового окружения провайдера МТС нет, получателей вышло бы ноль, и тест
|
||
// проверял бы «0 == 0», то есть ничего.
|
||
$this->app->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
|
||
new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]),
|
||
new OperatorNormalizer,
|
||
new AllowedSmsOperators(new OperatorNormalizer),
|
||
));
|
||
|
||
ClientSmsContact::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'phone' => '79990000001',
|
||
'name' => 'А',
|
||
'operator' => 'МТС',
|
||
]);
|
||
|
||
$key = (string) Str::uuid();
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['source' => 'base', 'idempotency_key' => $key]))
|
||
->assertCreated();
|
||
|
||
$frozenAfterFirst = (string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub;
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['source' => 'base', 'idempotency_key' => $key]))
|
||
->assertOk();
|
||
|
||
$frozenAfterSecond = (string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub;
|
||
|
||
expect($frozenAfterSecond)->toBe($frozenAfterFirst)
|
||
->and($frozenAfterFirst)->not->toBe('0.00'); // заморозка вообще была — иначе тест пустой
|
||
});
|
||
|
||
it('такая же рассылка за 10 минут спрашивает подтверждение, а с ответом «да» уходит', function () {
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => (string) Str::uuid()]))
|
||
->assertCreated();
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => (string) Str::uuid()]))
|
||
->assertStatus(409)
|
||
->assertJsonPath('code', 'duplicate_recent');
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload([
|
||
'idempotency_key' => (string) Str::uuid(),
|
||
'confirmed' => true,
|
||
]))->assertCreated();
|
||
|
||
expect(ClientSmsCampaign::where('tenant_id', $this->tenant->id)->count())->toBe(2);
|
||
});
|
||
|
||
it('другой текст за те же 10 минут вопросов не вызывает', function () {
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => (string) Str::uuid()]))
|
||
->assertCreated();
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload([
|
||
'body' => 'Совсем другое сообщение',
|
||
'idempotency_key' => (string) Str::uuid(),
|
||
]))->assertCreated();
|
||
});
|
||
|
||
it('ключ заказа чужого клиента на нашу рассылку не влияет', function () {
|
||
$key = (string) Str::uuid();
|
||
|
||
$this->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => $key]))->assertCreated();
|
||
|
||
// Другой клиент с тем же ключом — своя рассылка, а не чужая.
|
||
$stranger = Tenant::factory()->create();
|
||
$strangerUser = User::factory()->create(['tenant_id' => $stranger->id]);
|
||
|
||
$res = $this->actingAs($strangerUser)
|
||
->postJson('/api/sms/campaigns', idemPayload(['idempotency_key' => $key]))
|
||
->assertCreated();
|
||
|
||
expect(ClientSmsCampaign::find($res->json('id'))->tenant_id)->toBe($stranger->id)
|
||
->and(ClientSmsCampaign::count())->toBe(2);
|
||
});
|