3ad2ffb771
Имя отправителя Лидерра регистрирует у МТС от лица клиента, поэтому письмо-разрешение нужно всегда. Собрал генератор бланка так, чтобы он покрывал все случаи и всегда отдавал целый документ — заполненный, где есть данные, и с прочерками, где их нет. - ConsentLetterBuilder — единый источник текста письма на все 8 комбинаций «объект (юр.наименование/название ИП/товарный знак/домен) × правообладатель (юрлицо/ИП/физлицо)» строго по образцам МТС. Паспорт физлица не храним — прочерк. - ConsentDocxWriter — рендер в РЕДАКТИРУЕМЫЙ Word (.docx) вместо PDF, чтобы клиент дописал недостающее; акцент про паспорт («должны совпадать с документом на право»). - consentForm(): убрана жёсткая блокировка (бланк отдаётся всегда), добавлен выбор правообладателя owner_type/owner_name — домен/знак могут быть на физлице даже у клиента-юрлица/ИП, тогда письмо от этого физлица. - Экран: подсказки «как назвать имя» под каждый вид, общее правило (до 11 знаков, латиницей), радио «на кого зарегистрирован домен/знак» + поле ФИО, кнопка бланка активна после ввода имени, ярлык «Скачать бланк разрешения (Word)». - consent-form.blade.php удалён (заменён docx); phpoffice/phpword ^1.4 в composer. Тесты: бэкенд ClientSms 116, фронт SMS-спеки 44 — зелёные; pint чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
451 lines
20 KiB
PHP
451 lines
20 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\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',
|
||
]);
|
||
|
||
$r2 = $this->postJson('/api/sms/campaigns', [
|
||
'title' => 'С именем', 'body' => 'Привет', 'source' => 'base',
|
||
]);
|
||
$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');
|
||
}
|
||
}
|
||
});
|