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

734 lines
34 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\ClientSmsSender;
use App\Models\Tenant;
use App\Models\TenantRequisites;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
/**
* Task 13a: клиентский «свой отправитель» (client_sms_senders) — заявка на имя,
* бронь платы в рекламном кошельке (AdWalletService), отключение имени и
* прокидывание активного имени в снимок sender_name кампании.
*
* Имя у оператора регистрирует Лидерра ОТ ЛИЦА клиента, поэтому от клиента нужно
* ДВА файла: подписанное согласие (бланк готовит портал) + документ-основание
* (право на имя). Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('local'); // сканы имени не пишем на реальный диск в тестах
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Фейковый скан документа-основания (право на имя). */
function fakeSenderDoc(string $name = 'ogrn.pdf'): UploadedFile
{
return UploadedFile::fake()->create($name, 120, 'application/pdf');
}
/** Фейковый скан подписанного согласия (бланк, который сформировал портал). */
function fakeConsentDoc(string $name = 'soglasie.pdf'): UploadedFile
{
return UploadedFile::fake()->create($name, 120, 'application/pdf');
}
/** Оба обязательных файла заявки на имя. */
function senderFiles(): array
{
return ['consent_document' => fakeConsentDoc(), 'document' => fakeSenderDoc()];
}
/** Заполнить реквизиты тенанта (нужны для бланка согласия). */
function fillRequisites(int $tenantId): TenantRequisites
{
return TenantRequisites::create([
'tenant_id' => $tenantId,
'subject_type' => 'legal_entity',
'contact_name' => 'Иван Иванов',
'contact_phone' => '+79990000000',
'inn' => '7701234567',
'legal_name' => 'ООО «Ромашка»',
'ogrn' => '1234567890123',
'legal_address' => 'Москва, ул. Тестовая, 1',
]);
}
it('GET /api/sms/sender без имени → sender:null, effective_name = liderra.ru', function () {
config(['services.sms.mts.naming' => '']);
$r = $this->getJson('/api/sms/sender');
$r->assertOk()
->assertJsonPath('sender', null)
->assertJsonPath('effective_name', 'liderra.ru')
->assertJsonPath('name_fee_rub_per_operator', '2500.00')
->assertJsonPath('requisites_ready', false);
});
it('GET /api/sms/sender с заполненными реквизитами → requisites_ready:true', function () {
fillRequisites($this->tenant->id);
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('requisites_ready', true);
});
it('POST /api/sms/sender (sandbox) → заявка pending, плата 2500.00, кошелёк не тронут', function () {
config(['services.sms.sandbox' => true]);
$r = $this->post('/api/sms/sender', [
'name' => 'mysite.ru',
'name_type' => 'website',
...senderFiles(),
]);
$r->assertSuccessful()
->assertJsonPath('status', ClientSmsSender::STATUS_PENDING);
$sender = ClientSmsSender::where('tenant_id', $this->tenant->id)->first();
expect($sender)->not->toBeNull();
expect($sender->status)->toBe(ClientSmsSender::STATUS_PENDING);
expect($sender->name)->toBe('mysite.ru');
expect($sender->name_type)->toBe(ClientSmsSender::TYPE_WEBSITE);
expect($sender->operators)->toBe(['mts']);
expect($sender->monthly_fee_rub)->toBe('2500.00');
expect($sender->consent_at)->not->toBeNull();
// Sandbox: заморозки нет — кошелёк не создан.
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('POST /api/sms/sender (реальный режим, денег хватает) → pending + бронь 2500.00 в кошельке', function () {
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
$r = $this->post('/api/sms/sender', [
'name' => 'mysite.ru',
'name_type' => 'website',
...senderFiles(),
]);
$r->assertSuccessful()
->assertJsonPath('status', ClientSmsSender::STATUS_PENDING);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($wallet->frozen_rub)->toBe('2500.00');
});
it('POST /api/sms/sender (реальный режим, денег НЕ хватает) → 409 и заявка не сохранена', function () {
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test');
$r = $this->post('/api/sms/sender', [
'name' => 'mysite.ru',
'name_type' => 'website',
...senderFiles(),
]);
$r->assertStatus(409);
// Заявка откачена (nested tx) — сироты pending нет.
expect(ClientSmsSender::where('tenant_id', $this->tenant->id)->count())->toBe(0);
// Бронь не появилась.
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($wallet->frozen_rub)->toBe('0.00');
});
it('POST /api/sms/sender при ACTIVE имени → 422', function () {
config(['services.sms.sandbox' => true]);
ClientSmsSender::create([
'tenant_id' => $this->tenant->id,
'name' => 'Активное', 'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_ACTIVE, 'operators' => ['mts'],
'monthly_fee_rub' => '2500.00',
]);
$this->post('/api/sms/sender', [
'name' => 'другое.ru', 'name_type' => 'website', ...senderFiles(),
])->assertStatus(422)
->assertJsonPath('message', 'У вас уже есть активное имя отправителя');
});
it('POST /api/sms/sender при PENDING заявке → 422', function () {
config(['services.sms.sandbox' => true]);
ClientSmsSender::create([
'tenant_id' => $this->tenant->id,
'name' => 'На рассмотрении', 'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_PENDING, 'operators' => ['mts'],
'monthly_fee_rub' => '2500.00',
]);
$this->post('/api/sms/sender', [
'name' => 'другое.ru', 'name_type' => 'website', ...senderFiles(),
])->assertStatus(422)
->assertJsonPath('message', 'Заявка на имя уже на рассмотрении');
});
it('store: кампания снимает АКТИВНОЕ имя отправителя, иначе liderra.ru', function () {
config(['services.sms.sandbox' => true]);
config(['services.sms.mts.naming' => '']);
Queue::fake();
ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']);
// Без активного имени → liderra.ru.
$r1 = $this->postJson('/api/sms/campaigns', [
'title' => 'Без имени', 'body' => 'Привет', 'source' => 'base',
]);
$r1->assertCreated();
expect(ClientSmsCampaign::find($r1->json('id'))->sender_name)->toBe('liderra.ru');
// Появилось активное имя → снимок MyBrand.
ClientSmsSender::create([
'tenant_id' => $this->tenant->id,
'name' => 'MyBrand', 'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_ACTIVE, 'operators' => ['mts'],
'monthly_fee_rub' => '2500.00',
]);
// Текст и источник те же, что у первой рассылки, — мягкая защита от двойного
// нажатия (строка листа 1.19) спросила бы подтверждение. Здесь повтор намеренный.
$r2 = $this->postJson('/api/sms/campaigns', [
'title' => 'С именем', 'body' => 'Привет', 'source' => 'base', 'confirmed' => true,
]);
$r2->assertCreated();
expect(ClientSmsCampaign::find($r2->json('id'))->sender_name)->toBe('MyBrand');
// index тоже отдаёт активное имя.
$this->getJson('/api/sms/campaigns')->assertOk()->assertJsonPath('sender_name', 'MyBrand');
});
it('POST /api/sms/sender/disable по PENDING → cancelled и бронь снята (frozen_rub → 0)', function () {
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
// Заводим заявку с бронью через реальный флоу.
$this->post('/api/sms/sender', [
'name' => 'mysite.ru', 'name_type' => 'website', ...senderFiles(),
])->assertSuccessful();
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('2500.00');
$r = $this->postJson('/api/sms/sender/disable');
$r->assertOk()->assertJsonPath('disabled', true);
$sender = ClientSmsSender::where('tenant_id', $this->tenant->id)->first();
expect($sender->status)->toBe(ClientSmsSender::STATUS_CANCELLED);
// Бронь освобождена.
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00');
});
it('POST /api/sms/sender БЕЗ документа-основания → 422', function () {
config(['services.sms.sandbox' => true]);
$this->postJson('/api/sms/sender', [
'name' => 'mysite.ru', 'name_type' => 'website', 'consent_document' => fakeConsentDoc(),
])->assertStatus(422)->assertJsonValidationErrors('document');
expect(ClientSmsSender::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
it('POST /api/sms/sender БЕЗ подписанного согласия → 422', function () {
config(['services.sms.sandbox' => true]);
$this->postJson('/api/sms/sender', [
'name' => 'mysite.ru', 'name_type' => 'website', 'document' => fakeSenderDoc(),
])->assertStatus(422)->assertJsonValidationErrors('consent_document');
expect(ClientSmsSender::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
it('POST /api/sms/sender сохраняет ОБА скана + исходные имена, пути наружу не отдаются', function () {
config(['services.sms.sandbox' => true]);
$r = $this->post('/api/sms/sender', [
'name' => 'ООО Ромашка',
'name_type' => 'legal',
'consent_document' => fakeConsentDoc('soglasie-romashka.pdf'),
'document' => fakeSenderDoc('ogrn-romashka.pdf'),
]);
$r->assertSuccessful()
->assertJsonPath('name_type', 'legal')
->assertJsonPath('doc_original_name', 'ogrn-romashka.pdf')
->assertJsonPath('consent_doc_original_name', 'soglasie-romashka.pdf')
->assertJsonMissingPath('doc_path') // серверный путь наружу не отдаём
->assertJsonMissingPath('consent_doc_path');
$sender = ClientSmsSender::where('tenant_id', $this->tenant->id)->first();
expect($sender->doc_original_name)->toBe('ogrn-romashka.pdf')
->and($sender->doc_path)->not->toBeNull()
->and($sender->consent_doc_original_name)->toBe('soglasie-romashka.pdf')
->and($sender->consent_doc_path)->not->toBeNull();
Storage::disk('local')->assertExists($sender->doc_path);
Storage::disk('local')->assertExists($sender->consent_doc_path);
});
it('POST /api/sms/sender отклоняет неизвестный тип имени', function () {
config(['services.sms.sandbox' => true]);
$this->post('/api/sms/sender', [
'name' => 'X', 'name_type' => 'company', ...senderFiles(),
], ['Accept' => 'application/json'])->assertStatus(422)->assertJsonValidationErrors('name_type');
});
it('POST /api/sms/sender/disable без имени → 404', function () {
$this->postJson('/api/sms/sender/disable')->assertStatus(404);
});
// ─── Бланк согласия (портал готовит PDF с данными клиента) ───────────────────
it('GET /api/sms/sender/consent-form с реквизитами → редактируемый Word (.docx)', function () {
fillRequisites($this->tenant->id);
$r = $this->get('/api/sms/sender/consent-form?name=MYSHOP&name_type=legal');
$r->assertOk();
// Клиенту нужно дописывать поля → отдаём Word, а не PDF.
expect(substr($r->getContent(), 0, 2))->toBe('PK'); // docx = ZIP
expect($r->headers->get('content-type'))->toContain('wordprocessingml');
});
it('GET /api/sms/sender/consent-form БЕЗ реквизитов → всё равно цельный бланк (Word с прочерками)', function () {
// Имя регистрирует Лидерра — бланк нужен всегда; чего не знаем, оставляем прочерки.
$r = $this->get('/api/sms/sender/consent-form?name=MYSHOP&name_type=legal');
$r->assertOk();
expect($r->headers->get('content-type'))->toContain('wordprocessingml');
// И при пустом ИНН — тоже отдаём бланк, не блокируем.
TenantRequisites::create([
'tenant_id' => $this->tenant->id,
'subject_type' => 'legal_entity',
'contact_name' => 'И', 'contact_phone' => '+79990000000',
'legal_name' => 'ООО «Без ИНН»', // inn пуст
]);
$r2 = $this->get('/api/sms/sender/consent-form?name=MYSHOP&name_type=legal');
$r2->assertOk();
expect($r2->headers->get('content-type'))->toContain('wordprocessingml');
});
it('GET /api/sms/sender/consent-form без name → 422 валидация', function () {
fillRequisites($this->tenant->id);
$this->getJson('/api/sms/sender/consent-form?name_type=legal')
->assertStatus(422)->assertJsonValidationErrors('name');
});
it('физлицо (домен на физлицо): requisites_ready:true по одному ФИО, бланк отдаётся', function () {
// У физлица нет ИНН/названия — только ФИО и телефон. Домен часто на физлицо.
TenantRequisites::create([
'tenant_id' => $this->tenant->id,
'subject_type' => 'individual',
'contact_name' => 'Пётр Петров',
'contact_phone' => '+79990000000',
]);
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('requisites_ready', true);
$r = $this->get('/api/sms/sender/consent-form?name=mysite.ru&name_type=website');
$r->assertOk();
expect($r->headers->get('content-type'))->toContain('wordprocessingml');
});
it('физлицо без ФИО → requisites_ready:false, но бланк всё равно отдаётся (прочерки)', function () {
TenantRequisites::create([
'tenant_id' => $this->tenant->id,
'subject_type' => 'individual',
'contact_name' => '', // ФИО не заполнено
'contact_phone' => '+79990000000',
]);
// requisites_ready честно false (экран подскажет заполнить), но бланк не блокируем.
$this->getJson('/api/sms/sender')->assertOk()->assertJsonPath('requisites_ready', false);
$r = $this->get('/api/sms/sender/consent-form?name=mysite.ru&name_type=website');
$r->assertOk();
expect($r->headers->get('content-type'))->toContain('wordprocessingml');
});
/** Достаёт видимый текст из word/document.xml сгенерированного .docx. */
function consentDocxText(string $content): string
{
$tmp = tempnam(sys_get_temp_dir(), 'docx');
file_put_contents($tmp, $content);
$zip = new ZipArchive;
$zip->open($tmp);
$xml = $zip->getFromName('word/document.xml');
$zip->close();
@unlink($tmp);
return strip_tags((string) $xml);
}
it('домен на ФИЗЛИЦЕ при клиенте-юрлице → письмо от физлица (ФИО + паспорт-прочерк), не от юрлица', function () {
// Клиент — юрлицо, но домен зарегистрирован на директоре-физлице.
fillRequisites($this->tenant->id); // legal_entity, ООО «Ромашка»
$r = $this->get('/api/sms/sender/consent-form?name=shop.ru&name_type=website&owner_type=individual&owner_name='.urlencode('Иван Петров'));
$r->assertOk();
$text = consentDocxText($r->getContent());
expect($text)->toContain('Я, Иван Петров');
expect($text)->toContain('владелец домена «shop.ru»');
expect($text)->toContain('паспортные'); // паспорт вписывается от руки
expect($text)->not->toContain('в лице генерального директора'); // НЕ юрлицо-форма
expect($text)->not->toContain('ООО «Ромашка»'); // реквизиты компании не подставляем — объект чужой
});
it('товарный знак на ФИЗЛИЦЕ при клиенте-ИП → письмо от физлица', function () {
TenantRequisites::create([
'tenant_id' => $this->tenant->id,
'subject_type' => 'sole_proprietor',
'contact_name' => 'Пётр Петров', 'contact_phone' => '+79990000000',
'legal_name' => 'ИП Петров', 'inn' => '500100732259',
]);
$r = $this->get('/api/sms/sender/consent-form?name=BREND&name_type=trademark&owner_type=individual&owner_name='.urlencode('Сергей Сидоров'));
$r->assertOk();
$text = consentDocxText($r->getContent());
expect($text)->toContain('Я, Сергей Сидоров');
expect($text)->toContain('правообладатель товарного знака');
expect($text)->not->toContain('ИП Петров');
});
it('домен без owner_type → письмо от лица клиента (юрлицо)', function () {
fillRequisites($this->tenant->id); // legal_entity
$r = $this->get('/api/sms/sender/consent-form?name=shop.ru&name_type=website');
$r->assertOk();
$text = consentDocxText($r->getContent());
expect($text)->toContain('в лице генерального директора'); // юрлицо-форма
expect($text)->toContain('ООО «Ромашка»');
});
it('GET /api/sms/sender/consent-form отдаёт Word для всех 8 комбинаций (объект × тип лица)', function () {
$subjects = [
'legal_entity' => ['legal', 'website', 'trademark'],
'sole_proprietor' => ['ip', 'website', 'trademark'],
'individual' => ['website', 'trademark'],
];
foreach ($subjects as $subject => $types) {
TenantRequisites::updateOrCreate(
['tenant_id' => $this->tenant->id],
[
'subject_type' => $subject,
'contact_name' => 'Пётр Петров',
'contact_phone' => '+79990000000',
'legal_name' => $subject === 'individual' ? '' : 'ООО «Ромашка»',
'inn' => $subject === 'individual' ? '' : '7707083893',
],
);
foreach ($types as $type) {
$r = $this->get("/api/sms/sender/consent-form?name=shop.ru&name_type={$type}");
$r->assertOk();
expect($r->headers->get('content-type'))->toContain('wordprocessingml');
}
}
});
// ─── Кнопка «включить имя обратно» (строка листа 4.5, Task 4) ────────────────
//
// Решение владельца В-134: кнопка списывает и включает прямо сейчас; денег не
// хватает — говорит, сколько не хватает, и не трогает ничего.
// Плана оказалось мало трижды подряд — В-143 (не смотрел, КТО отключил имя),
// В-144 (отменённая заявка и отключённое имя — одно слово в базе), В-145
// (списал бы второй раз за уже оплаченный срок).
/** Карточка имени в нужном состоянии. Имена помощников в Pest ГЛОБАЛЬНЫЕ — отсюда префикс. */
function enableTestSender(int $tenantId, array $attrs = []): ClientSmsSender
{
return ClientSmsSender::create(array_merge([
'tenant_id' => $tenantId,
'name' => 'MYSHOP',
'name_type' => ClientSmsSender::TYPE_LEGAL,
'status' => ClientSmsSender::STATUS_SUSPENDED,
'operators' => ['mts'],
'monthly_fee_rub' => '100.00',
'approved_at' => '2026-04-25 10:00:00',
'paid_until' => '2026-06-25',
'debt_since' => '2026-06-26',
'suspended_reason' => ClientSmsSender::SUSPENDED_BY_DEBT,
'note' => 'Долг более 29 дней — имя отключено, погасить у МТС',
'doc_path' => 'sms-senders/1/osnovanie.pdf',
'consent_doc_path' => 'sms-senders/1/soglasie.pdf',
], $attrs));
}
it('4.5 кнопка: имя отключено за долг и денег хватает → включилось, списан ровно месяц', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
$sender = enableTestSender($this->tenant->id);
$r = $this->postJson('/api/sms/sender/enable');
$r->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('charged_rub', '100.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE);
// Один месяц вперёд ОТ СЕГОДНЯ, старый долг прощён (В-133).
expect($sender->paid_until->toDateString())->toBe('2026-08-29');
expect($sender->debt_since)->toBeNull();
// Графа «почему отключено» обнуляется — на ней держатся деньги (В-143).
expect($sender->suspended_reason)->toBeNull();
// Документы заново не запрашиваем: заявка уже согласована.
expect($sender->approved_at)->not->toBeNull();
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('4900.00');
});
it('4.5 кнопка: денег не хватает → 422 с недостающей суммой, НИЧЕГО не тронуто', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '30.00', null, 'test');
$sender = enableTestSender($this->tenant->id);
$r = $this->postJson('/api/sms/sender/enable');
$r->assertStatus(422)->assertJsonPath('missing_rub', '70.00');
expect($r->json('message'))->toContain('70.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED);
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('30.00');
});
it('4.5 кнопка: замороженные под рассылку деньги свободными НЕ считаются', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '120.00', null, 'test');
$sender = enableTestSender($this->tenant->id);
// 100 из 120 заморожены под кампанию → свободно 20, платы 100 не хватает.
app(AdWalletService::class)->freeze($this->tenant->id, 'sms', 'campaign', 777, '100.00');
$this->postJson('/api/sms/sender/enable')
->assertStatus(422)
->assertJsonPath('missing_rub', '80.00');
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED);
});
it('4.5 кнопка: имя, отключённое ВЛАДЕЛЬЦЕМ, клиент включить не может (В-143)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
$sender = enableTestSender($this->tenant->id, [
'suspended_reason' => ClientSmsSender::SUSPENDED_BY_OWNER,
'note' => 'Отключено владельцем — погасить у МТС',
]);
$this->postJson('/api/sms/sender/enable')->assertStatus(422);
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED);
// Денег не тронули: имя у МТС погашено, платить за него не за что.
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('5000.00');
});
it('4.5 кнопка: у имени с ПУСТОЙ причиной отключения включения нет — безопасное направление (В-143)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
// Строки старше миграции 100600: кто отключил — неизвестно.
$sender = enableTestSender($this->tenant->id, ['suspended_reason' => null]);
$this->postJson('/api/sms/sender/enable')->assertStatus(422);
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED);
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('5000.00');
});
it('4.5 кнопка: НЕ согласованную заявку не воскрешает и денег за неё не берёт (В-144)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
// Клиент нажал «Отменить заявку» на имени, которое ещё висело на согласовании.
$sender = enableTestSender($this->tenant->id, [
'status' => ClientSmsSender::STATUS_CANCELLED,
'approved_at' => null,
'paid_until' => null,
'debt_since' => null,
'suspended_reason' => null,
'note' => null,
]);
$this->postJson('/api/sms/sender/enable')->assertStatus(422);
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_CANCELLED);
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('5000.00');
});
it('4.5 кнопка: имя, выключенное самим клиентом и оплаченное вперёд, включается БЕЗ повторной платы (В-145)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
// Кошелёк пуст намеренно: за этот срок уже заплачено, денег спрашивать не за что.
$sender = enableTestSender($this->tenant->id, [
'status' => ClientSmsSender::STATUS_CANCELLED,
'paid_until' => '2026-08-15',
'debt_since' => null,
'suspended_reason' => null,
'note' => null,
]);
$this->postJson('/api/sms/sender/enable')
->assertOk()
->assertJsonPath('charged_rub', '0.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE);
// Оплаченный срок НЕ сдвигаем — иначе клиент получил бы лишние дни даром.
expect($sender->paid_until->toDateString())->toBe('2026-08-15');
// Документы на месте — заново их не просим.
expect($sender->getRawOriginal('doc_path'))->toBe('sms-senders/1/osnovanie.pdf');
expect($sender->getRawOriginal('consent_doc_path'))->toBe('sms-senders/1/soglasie.pdf');
});
it('4.5 кнопка: имя выключено клиентом, но срок оплаты истёк → берём месяц вперёд', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '500.00', null, 'test');
$sender = enableTestSender($this->tenant->id, [
'status' => ClientSmsSender::STATUS_CANCELLED,
'paid_until' => '2026-06-15',
'debt_since' => null,
'suspended_reason' => null,
'note' => null,
]);
$this->postJson('/api/sms/sender/enable')
->assertOk()
->assertJsonPath('charged_rub', '100.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE);
expect($sender->paid_until->toDateString())->toBe('2026-08-29');
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('400.00');
});
it('4.5 кнопка: за один месяц не спишет дважды — ключ общий с ночным работником', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
$sender = enableTestSender($this->tenant->id);
// Ночной работник в этом месяце уже взял плату (тот же ключ месяца).
app(AdWalletService::class)->charge(
$this->tenant->id, 'sms', 'sender', $sender->id, '100.00',
'sms:sender:'.$sender->id.':2026-07',
);
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('4900.00');
$this->postJson('/api/sms/sender/enable')->assertOk();
// Второй раз за тот же месяц денег не взяли.
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('4900.00');
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_ACTIVE);
});
it('4.5 кнопка: имя уже работает → 422, повторного списания нет', function () {
config(['services.sms.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
enableTestSender($this->tenant->id, [
'status' => ClientSmsSender::STATUS_ACTIVE,
'paid_until' => '2026-08-25',
'debt_since' => null,
'suspended_reason' => null,
]);
$this->postJson('/api/sms/sender/enable')->assertStatus(422);
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub)->toBe('5000.00');
});
it('4.5 кнопка: имени нет вовсе → 404', function () {
$this->postJson('/api/sms/sender/enable')->assertStatus(404);
});
it('4.5 песочница: имя включается без движения денег, но экран называет НАСТОЯЩУЮ плату (В-148)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => true]);
// Кошелёк пуст: в песочнице деньги не двигаются, и это не должно мешать.
$sender = enableTestSender($this->tenant->id);
// 🔴 Экран обязан сказать «спишется 100 ₽», а НЕ «уже оплачено»: срок оплаты
// истёк в июне, ноль здесь означал бы неправду про причину.
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('can_enable', true)
->assertJsonPath('enable_charge_rub', '100.00');
$this->postJson('/api/sms/sender/enable')
->assertOk()
->assertJsonPath('charged_rub', '0.00'); // денег и правда не двигали
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_ACTIVE);
expect(AdWallet::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
it('4.5 экран получает готовый ответ «можно ли включить» и «сколько спишется» (В-146)', function () {
Carbon::setTestNow('2026-07-29 10:00:00');
config(['services.sms.sandbox' => false]);
$sender = enableTestSender($this->tenant->id);
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('can_enable', true)
->assertJsonPath('enable_charge_rub', '100.00');
// Отключил владелец — кнопки быть не должно.
$sender->update(['suspended_reason' => ClientSmsSender::SUSPENDED_BY_OWNER]);
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('can_enable', false);
// Оплачено вперёд — кнопка есть, но спишется ноль.
$sender->update([
'status' => ClientSmsSender::STATUS_CANCELLED,
'suspended_reason' => null,
'paid_until' => '2026-08-15',
]);
$this->getJson('/api/sms/sender')
->assertOk()
->assertJsonPath('can_enable', true)
->assertJsonPath('enable_charge_rub', '0.00');
});