456294b8ce
Две разные защиты, и они не взаимозаменяемы. Жёсткая: у заказа есть ключ, который экран придумывает при открытии формы. Тот же ключ = тот же самый заказ: двойной клик, обрыв связи, повтор браузера возвращают первую рассылку и денег не трогают. Молча — человек ничего нового не просил. Гонку добивает уникальный индекс в базе, нарушение ловится и отдаёт первую рассылку. Мягкая: заказ другой, но текст и источник те же, и десяти минут не прошло. Здесь решает человек — сервер отвечает вопросом «вы уже это запускали, отправить ещё раз?», а с подтверждением рассылка уходит. Строки приёмочного листа 1.18-1.20 (серверная часть). Защита проверена вырезанием: убрать проверку ключа — 2 красных теста. Мягкая защита закономерно задела два прежних теста, где рассылка с тем же текстом создаётся дважды подряд намеренно, — им дописано подтверждение. ClientSms 156/156, приём лидов 17/17, phpstan по своим файлам чисто.
147 lines
6.4 KiB
PHP
147 lines
6.4 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\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);
|
||
});
|