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'); });