1dece3a2b4
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2). Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз, при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит. Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же — число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл текст на второй кусок. Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием: считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1. ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто.
206 lines
8.8 KiB
PHP
206 lines
8.8 KiB
PHP
<?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());
|
||
});
|