Files
portal/app/tests/Feature/ClientSms/IdempotencyTest.php
T
Дмитрий 456294b8ce feat(смс-клиент): двойное нажатие «Отправить» — одна рассылка, не две
Две разные защиты, и они не взаимозаменяемы.

Жёсткая: у заказа есть ключ, который экран придумывает при открытии формы. Тот же
ключ = тот же самый заказ: двойной клик, обрыв связи, повтор браузера возвращают
первую рассылку и денег не трогают. Молча — человек ничего нового не просил.
Гонку добивает уникальный индекс в базе, нарушение ловится и отдаёт первую рассылку.

Мягкая: заказ другой, но текст и источник те же, и десяти минут не прошло. Здесь
решает человек — сервер отвечает вопросом «вы уже это запускали, отправить ещё раз?»,
а с подтверждением рассылка уходит.

Строки приёмочного листа 1.18-1.20 (серверная часть). Защита проверена вырезанием:
убрать проверку ключа — 2 красных теста.

Мягкая защита закономерно задела два прежних теста, где рассылка с тем же текстом
создаётся дважды подряд намеренно, — им дописано подтверждение.

ClientSms 156/156, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:38:01 +03:00

147 lines
6.4 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\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,
));
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);
});