2026-07-27 18:38:01 +03:00
|
|
|
|
<?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;
|
2026-07-29 15:25:37 +03:00
|
|
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
2026-07-27 18:38:01 +03:00
|
|
|
|
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,
|
2026-07-29 15:25:37 +03:00
|
|
|
|
new AllowedSmsOperators(new OperatorNormalizer),
|
2026-07-27 18:38:01 +03:00
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|