Files
portal/app/tests/Feature/ClientSms/IdempotencyTest.php
T
Дмитрий 65e270583c feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 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>
2026-07-29 15:25:37 +03:00

149 lines
6.5 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\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);
});