Files
portal/app/tests/Feature/ClientSms/IdempotencyTest.php
T

147 lines
6.4 KiB
PHP
Raw Normal View History

<?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);
});