6d52f390a0
Строка листа 4.5, Этап 4 Task 4. Раньше имя, которое клиент выключил сам или которое отключили за долг, можно было вернуть только одним способом: заказать заново, снова приложив документы и снова дождавшись согласования у МТС. Теперь у клиента есть кнопка: она берёт плату за месяц вперёд и включает имя сразу — решение владельца. Денег не хватает — портал говорит, СКОЛЬКО именно не хватает, и не трогает ни имя, ни кошелёк. Документы заново не запрашиваются: заявка уже согласована, сканы на месте. Ключ списания у кнопки тот же, что у ночного работника, поэтому за один календарный месяц имя оплачивается один раз — кто бы его ни включал. Сумма нигде не зашита, берётся из карточки имени и вырастет сама, когда подключим остальных операторов. Плана оказалось мало ЧЕТЫРЕЖДЫ, и все четыре раза — про деньги. В-143: план не смотрел, КТО отключил имя. Имя отключает и владелец руками, и оно у МТС погашено; клиент нажал бы кнопку, портал взял бы с него плату, а имя не работало бы. Это ровно то, что работнику запретили в прошлой задаче. Кнопка теперь смотрит в ту же графу «почему отключено» и при включении её гасит. В-144: слово «отменено» стоит и у имени, которое когда-то работало, и у заявки, которую клиент отозвал ещё на согласовании. Вторую план включил бы как рабочее имя и списал за неё деньги — за имя, которого у оператора нет. Отличаю по отметке согласования: её ставит ровно одно место, кнопка одобрения у владельца. В-145: имя, оплаченное вперёд, план списал бы второй раз — а при пустом кошельке ещё и отказался бы включить имя, за которое уже заплачено. Смотрю на «оплачено до»: срок в будущем — включаю без списания и срок не двигаю. В-146: план отдавал экрану решение «показывать ли кнопку». После первых двух правок правило перестало быть про один признак, и оно переехало на сервер: адрес имени отдаёт готовые «можно ли включить» и «сколько спишется», экран не считает. Отдельно В-148 — его поймал только браузер, мимо прошли 12 серверных и 66 фронтовых тестов. Экран писал «плата за текущий срок уже внесена» у имени, отключённого за долг с апреля. Ноль в сумме приходил не от оплаты, а от песочницы — одно значение с двумя разными причинами. Развёл: сумма считается только по сроку оплаты, песочница гасит сам денежный шаг. Проверено вырезанием, восемь вырезов, все вернуты, каждый покраснел там, где вырезан: проверка денег, отбор по причине отключения, отметка согласования, ветка «оплачено вперёд», ключ месяца, песочница в решении о сумме, кнопка на экране, учёт «можно ли» на экране. Живой прогон под боевой ролью crm_app_user, семь нажатий: 50 ₽ при плате 100 — отказ «Не хватает 50.00 ₽», не тронуто ничего; пополнил до 550 — имя работает, списано ровно 100 ₽, оплачено до 29.08; имя владельца при 5000 ₽ — отказ, деньги целы; неодобренная заявка при 5000 ₽ — отказ, деньги целы; второе нажатие — «имя уже работает», в движениях одно списание. Пара на пометку клиента: без неё то же нажатие не делает ничего, с ней — списывает и включает. Живой прогон в браузере: у имени с долгом видна кнопка и строка «Спишется 2500.00 ₽ — плата за имя за месяц»; нажал — карточка стала «Ваше имя: MYSHOP (активно)». У имени, отключённого владельцем, кнопки нет вовсе. Прогоны: клиентские СМС 275/275 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1683 + 3 пропущенных, phpstan ровно 2 чужие давние, pint чисто. Проверка типов фронта: 5 ошибок в 5 файлах вместо прежних 8 в 6 — задал явный тип ответа в спеке, который и так правил, и три давние ошибки ушли вместе с моими. Схема базы не менялась, миграций нет.
734 lines
34 KiB
PHP
734 lines
34 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);
|
||
});
|
||
|
||
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');
|
||
});
|