533b9b33f7
Владелец увидел на боевой карточке «Причина: Вход в кабинет МТС потерян —
нужен вход руками (npm run login)». Перевод на человеческий существовал с
07.08, но работал ТОЛЬКО НА ЗАПИСИ: шесть боевых кампаний (5, 7, 10, 12, 13,
14) записаны раньше и продолжали ехать на экран сырыми, а 14-я — с внутренним
адресом кабинета marketolog.
Чистка перенесена на МОМЕНТ ПОКАЗА — PrichinaDlyaKlienta::dlyaEkrana плюс
CampaignController::dlyaKlienta: старые строки чинятся сами, базу трогать не
надо, технический текст в базе остаётся для админки. Слова МОДЕРАТОРА
при status=rejected проходят дословно — иначе клиент не поймёт, что править.
Закрыты ещё три дыры того же класса:
— подметальщик брошенных прогонов писал «кабинет МТС» мимо перевода;
— колокольчик и письмо клиенту брали сырой текст робота;
— перевод не был идемпотентным: второй проход вырождал уже переведённую
фразу в общую, то есть показывал клиенту текст мутнее записанного.
СМС — та же беда и вдобавок неправда: клиенту показывалось «имя отключено,
погасить у МТС», хотя денег МТС он не должен, долг перед рекламным кошельком
Лидерры. Тексты вынесены в ClientSmsSender::NOTE_DEBT_SUSPENDED /
NOTE_OWNER_SUSPENDED и переписаны по-человечески; служебный смысл остался в
suspended_reason и в записи журнала. Подсказка про регистрацию имени и
документ-согласие теперь говорят «оператор», а не «МТС».
Названия сетей (МТС/Билайн/МегаФон/Теле2) на экране СМС оставлены намеренно:
это сети получателей, которые клиент сам выбирает, а не наш посредник.
Сторожа: PrichinaNaEkraneTest — 12 проверок, боевые строки насквозь через API;
PometkaImeniBezPosrednikaTest — 2; плюс 4 в PrichinaDlyaKlientaTest.
ClientTg 412 зелёных, ClientSms 453, экраны 126, статанализ 0, стиль чисто.
Правлены два чужих сторожа, объяснение — прямо в файлах: RejectNotifyTest
подставлял слова модератора к статусу «идёт работа» (в жизни такой пары нет),
NameFeeJobTest сверял текст клиентской записки вместо признака suspended_reason.
756 lines
35 KiB
PHP
756 lines
35 KiB
PHP
<?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);
|
||
});
|
||
|
||
/**
|
||
* Экран обязан говорить правду по КАЖДОЙ сети: с 05.08.2026 номера с
|
||
* несогласованным именем не уходят вовсе, и одно имя «на всех» стало
|
||
* полуправдой (см. ClientSmsSenderOverview).
|
||
*/
|
||
it('GET /api/sms/sender несёт картину по всем четырём сетям', function () {
|
||
$r = $this->getJson('/api/sms/sender');
|
||
|
||
$r->assertOk()
|
||
->assertJsonCount(4, 'per_operator')
|
||
->assertJsonPath('per_operator.0.operator', 'mts')
|
||
->assertJsonPath('per_operator.1.operator', 'beeline')
|
||
->assertJsonPath('per_operator.2.operator', 'megafon')
|
||
->assertJsonPath('per_operator.3.operator', 'tele2');
|
||
|
||
// У каждой строки есть ответ на «уйдёт ли» и «почему» — без них экран
|
||
// не смог бы объяснить клиенту пропавшую половину базы.
|
||
foreach ($r->json('per_operator') as $row) {
|
||
expect($row)->toHaveKeys(['operator', 'will_send', 'reason', 'sender_name']);
|
||
}
|
||
});
|
||
|
||
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' => ClientSmsSender::NOTE_DEBT_SUSPENDED,
|
||
'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' => ClientSmsSender::NOTE_OWNER_SUSPENDED,
|
||
]);
|
||
|
||
$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');
|
||
});
|