Files
portal/app/tests/Feature/ClientSms/OptoutTailTest.php
T
Дмитрий 1dece3a2b4 feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2).
Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно
в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз,
при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит.

Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же —
число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе
число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл
текст на второй кусок.

Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием:
считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1.

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

206 lines
8.8 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\Jobs\SendClientSmsCampaignJob;
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\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsUnsubscribeLinkService;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строки листа 1.10–1.13: хвост «Отказ: liderra.ru/s/…» в тексте рассылки.
*
* Здесь сходятся текст и деньги. Токен свой у каждого номера, поэтому текст
* собирается на каждый номер отдельно — но длина хвоста постоянная, и цена
* считается один раз при создании, УЖЕ с хвостом. Если расчёт и сборка разойдутся
* хоть на символ, клиент заплатит за одну длину, а уйдёт другая — поэтому длина
* проверяется против настоящей строки, а не против числа в коде (В-21).
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Провайдер-соглядатай: запоминает тексты, которые реально ушли бы в линию. */
function tailSpyProvider(): SmsProvider
{
return new class implements SmsProvider
{
/** @var array<string, string> номер => текст */
public array $bodies = [];
public function key(): string
{
return 'mts';
}
public function servesOperators(): array
{
return ['mts'];
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->bodies[$message->phone] = $message->body;
return new SmsSendResult('MSG-TAIL-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
/** Кампания «по своей базе» в очереди — как её создаёт контроллер. */
function tailCampaign(int $tenantId, string $body, bool $withTail): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => $body,
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'with_optout_link' => $withTail,
'segments' => 1,
'planned_count' => 2,
'total_sms' => 2,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '0.00',
'created_by' => null,
]);
}
it('предпросмотр считает длину ВМЕСТЕ с хвостом, а не без него', function () {
$body = str_repeat('а', 60);
$with = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => $body, 'with_optout_link' => true,
])->assertOk()->json();
$without = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => $body, 'with_optout_link' => false,
])->assertOk()->json();
expect($with['segments'])->toBeGreaterThan($without['segments'])
->and((float) $with['estimated_cost_rub'])->toBeGreaterThan((float) $without['estimated_cost_rub']);
});
it('предпросмотр отдельно говорит, сколько кусков было бы без хвоста (строка 1.12)', function () {
$res = $this->postJson('/api/sms/preview', [
'source' => 'manual', 'phones' => ['79990000001'],
'body' => str_repeat('а', 60), 'with_optout_link' => true,
])->assertOk();
// 60 символов — один кусок; 60 + хвост — уже два. Экран обязан сказать это прямо.
$res->assertJsonPath('segments', 2)
->assertJsonPath('segments_without_tail', 1);
});
it('галочка отказа включена по умолчанию — поле в запросе не пришло', function () {
$this->postJson('/api/sms/campaigns', [
'title' => 'Тест', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => str_repeat('а', 60),
])->assertCreated();
$campaign = ClientSmsCampaign::latest('id')->first();
expect($campaign->with_optout_link)->toBeTrue()
->and($campaign->segments)->toBe(2); // цена зафиксирована уже с хвостом
});
it('клиент снял галочку — хвоста нет, кусков меньше и цена ниже', function () {
$body = str_repeat('а', 60);
$this->postJson('/api/sms/campaigns', [
'title' => 'С хвостом', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => true,
])->assertCreated();
$withTail = ClientSmsCampaign::latest('id')->first();
$this->postJson('/api/sms/campaigns', [
'title' => 'Без хвоста', 'source' => 'manual',
'phones' => ['79990000001'], 'body' => $body, 'with_optout_link' => false,
])->assertCreated();
$without = ClientSmsCampaign::latest('id')->first();
expect($without->with_optout_link)->toBeFalse()
->and($without->segments)->toBe(1)
->and($withTail->segments)->toBe(2)
// Цена — не выдуманное число, а строго меньше, чем за ту же рассылку с хвостом.
->and((float) $without->estimated_cost_rub)
->toBeLessThan((float) $withTail->estimated_cost_rub);
});
it('в линию уходит текст с личной ссылкой ИМЕННО этого номера', function () {
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', true);
$spy = tailSpyProvider();
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
$links = app(ClientSmsUnsubscribeLinkService::class);
$first = $links->tokenFor($this->tenant->id, '79990000001');
$second = $links->tokenFor($this->tenant->id, '79990000002');
expect($first)->not->toBe($second)
->and($spy->bodies['79990000001'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$first)
->and($spy->bodies['79990000002'])->toBe('Здравствуйте! Отказ: liderra.ru/s/'.$second);
});
it('галочка снята — в линию уходит ровно исходный текст, без хвоста', function () {
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
$campaign = tailCampaign($this->tenant->id, 'Здравствуйте!', false);
$spy = tailSpyProvider();
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
new ClientSmsRecipientSelector(new SmsRouter([$spy]), new OperatorNormalizer),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(ClientSmsUnsubscribeLinkService::class),
);
expect($spy->bodies['79990000001'])->toBe('Здравствуйте!');
});
it('длина хвоста в расчёте цены равна длине настоящего хвоста (В-21)', function () {
$links = app(ClientSmsUnsubscribeLinkService::class);
$real = $links->tailFor($this->tenant->id, '79990000001');
expect(mb_strlen($real))->toBe(ClientSmsUnsubscribeLinkService::tailLength());
});