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 номер => текст */ 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()); });