Files
portal/app/tests/Feature/Sales/SalesAttachmentApiTest.php
T
Дмитрий 277800149f @
fix(sales): строгое разделение двух ролей начальника — экраны «мои» больше не показывают отдел

Начальник продаёт сам и руководит отделом. Правило «начальник видит всё»
применялось к ЧЕЛОВЕКУ, а не к экрану, поэтому разделы меню врали:

- «Потенциальные клиенты» показывали все карточки отдела — точную копию
  «Воронки отдела» (это владелец и заметил);
- «Мои клиенты» и «Сводка» — всех клиентов отдела;
- «Привязать клиента» — очередь заявок отдела, причём в ЧУЖОМ формате
  ({pending, history} вместо {data}), форма получала не те данные.

Новое правило: роли разделяются по ЭКРАНУ. Любой запрос по умолчанию отдаёт
только личное — включая начальника. Весь отдел выдаётся только по явному
?scope=department, и просят его только экраны раздела НАЧАЛЬНИК. Менеджеру
параметр ничего не даёт: проверка по роли, не по параметру.

ownedTenantIds теперь ВСЕГДА личные привязки (тип сузился с ?array до array),
добавлен visibleTenantIds для области видимости запроса.

Правом начальника осталось открыть ЛЮБУЮ карточку — кандидата и клиента:
иначе из «Воронки отдела» не открылась бы карточка чужого менеджера.
Ограничены только списки, не доступ к записи.

Следствие: у начальника сейчас 0 своих кандидатов и клиентов, поэтому его
личные экраны станут пустыми — это правильно, а не поломка.

Pest 272/272, Vitest зелёный, vue-tsc чист, Larastan без своих ошибок.
Спека — §23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 15:59:56 +03:00

325 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\SalesAttachmentRequest;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
/**
* TDD: SalesAttachmentController — HTTP endpoints для заявок на привязку клиентов.
*
* Покрывает Task 2.3:
* - POST /api/sales/attachments → менеджер подаёт заявку
* - GET /api/sales/attachments → список (менеджер/начальник)
* - POST /api/sales/attachments/{id}/decide → решение начальника
*
* Аутентификация: Sanctum::actingAs($user, [], 'sales').
* Изоляция: DatabaseTransactions + SharesSupplierPdo (admin-db middleware).
* DB_DATABASE=liderra_testing.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── helpers ──────────────────────────────────────────────────────────────────
function apiManager(array $attrs = []): SalesUser
{
return SalesUser::create(array_merge([
'name' => 'Менеджер-API '.uniqid(),
'email' => 'apimgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
], $attrs));
}
function apiHead(array $attrs = []): SalesUser
{
return SalesUser::create(array_merge([
'name' => 'Начальник-API '.uniqid(),
'email' => 'apihead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
], $attrs));
}
function apiTenant(string $email = ''): Tenant
{
return Tenant::factory()->create([
'contact_email' => $email !== '' ? $email : 'apiclient'.uniqid().'@example.com',
]);
}
// ── store: POST /api/sales/attachments ───────────────────────────────────────
test('manager POST /api/sales/attachments with existing client email → 201 pending', function () {
Mail::fake();
$manager = apiManager();
$tenant = apiTenant('known-client@example.com');
$response = $this->actingAs($manager, 'sales')
->postJson('/api/sales/attachments', ['login' => 'known-client@example.com']);
$response->assertCreated();
expect($response->json('status'))->toBe('pending');
expect($response->json('tenant_id'))->toBe($tenant->id);
expect($response->json('status_label'))->toBe('На рассмотрении');
});
test('manager POST with unknown login → 201 not_found', function () {
Mail::fake();
$manager = apiManager();
$response = $this->actingAs($manager, 'sales')
->postJson('/api/sales/attachments', ['login' => 'nobody-xyz@unknown.test']);
$response->assertCreated();
expect($response->json('status'))->toBe('not_found');
expect($response->json('tenant_id'))->toBeNull();
expect($response->json('status_label'))->toBe('Клиент не найден');
});
test('manager POST for client already assigned to them → 422 with plain-Russian message', function () {
Mail::fake();
$manager = apiManager();
$tenant = apiTenant('own-api-client@example.com');
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
$response = $this->actingAs($manager, 'sales')
->postJson('/api/sales/attachments', ['login' => 'own-api-client@example.com']);
$response->assertStatus(422);
expect($response->json('message'))->toBe('Этот клиент уже ваш.');
});
test('POST without login field → 422 validation error', function () {
$manager = apiManager();
$this->actingAs($manager, 'sales')
->postJson('/api/sales/attachments', [])
->assertStatus(422);
});
// ── index: GET /api/sales/attachments ────────────────────────────────────────
test('manager GET /api/sales/attachments sees only their own requests', function () {
Mail::fake();
$managerA = apiManager();
$managerB = apiManager();
// Создаём заявки для обоих менеджеров
SalesAttachmentRequest::create([
'sales_user_id' => $managerA->id,
'login_input' => 'client-a@example.com',
'status' => 'pending',
]);
SalesAttachmentRequest::create([
'sales_user_id' => $managerB->id,
'login_input' => 'client-b@example.com',
'status' => 'pending',
]);
$response = $this->actingAs($managerA, 'sales')
->getJson('/api/sales/attachments');
$response->assertOk();
$data = $response->json('data');
expect($data)->not->toBeNull()->toHaveCount(1);
expect($data[0]['login_input'])->toBe('client-a@example.com');
});
test('head GET /api/sales/attachments sees pending queue including other managers requests', function () {
Mail::fake();
$manager = apiManager();
$head = apiHead();
// Pending заявка от менеджера
SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => 'head-visible@example.com',
'status' => 'pending',
]);
$response = $this->actingAs($head, 'sales')
->getJson('/api/sales/attachments?scope=department');
$response->assertOk();
expect($response->json('pending'))->not->toBeNull();
expect($response->json('history'))->not->toBeNull();
$pending = $response->json('pending');
$logins = array_column($pending, 'login_input');
expect($logins)->toContain('head-visible@example.com');
});
test('head GET index returns manager_name in pending rows', function () {
Mail::fake();
$manager = apiManager(['name' => 'Иван Тестов '.uniqid()]);
$head = apiHead();
SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => 'head-mgr-name@example.com',
'status' => 'pending',
]);
$response = $this->actingAs($head, 'sales')
->getJson('/api/sales/attachments?scope=department');
$response->assertOk();
$pending = $response->json('pending');
expect($pending)->not->toBeEmpty();
// Ищем нашу заявку
$found = collect($pending)->firstWhere('login_input', 'head-mgr-name@example.com');
expect($found)->not->toBeNull();
expect($found['manager_name'])->toContain('Иван Тестов');
});
// ── decide: POST /api/sales/attachments/{id}/decide ──────────────────────────
test('head POST decide approve → 200, assignment created', function () {
Mail::fake();
$manager = apiManager();
$head = apiHead();
$tenant = apiTenant('decide-test@example.com');
$req = SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => 'decide-test@example.com',
'tenant_id' => $tenant->id,
'status' => 'pending',
]);
$response = $this->actingAs($head, 'sales')
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'approve']);
$response->assertOk();
expect($response->json('status'))->toBe('approved');
expect($response->json('status_label'))->toBe('Одобрено');
// Spot-check: assignment создан
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($assignment)->not->toBeNull();
expect($assignment->sales_user_id)->toBe($manager->id);
});
test('manager POST decide → 403 plain Russian message', function () {
Mail::fake();
$manager = apiManager();
$otherManager = apiManager();
$tenant = apiTenant('mgr-decide@example.com');
$req = SalesAttachmentRequest::create([
'sales_user_id' => $otherManager->id,
'login_input' => 'mgr-decide@example.com',
'tenant_id' => $tenant->id,
'status' => 'pending',
]);
$response = $this->actingAs($manager, 'sales')
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'approve']);
$response->assertStatus(403);
expect($response->json('message'))->toBe('Только начальник может решать по заявкам.');
});
test('decide with invalid action → 422 validation error', function () {
$head = apiHead();
$manager = apiManager();
$tenant = apiTenant('bad-action@example.com');
$req = SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => 'bad-action@example.com',
'tenant_id' => $tenant->id,
'status' => 'pending',
]);
$this->actingAs($head, 'sales')
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'invalidaction'])
->assertStatus(422);
});
// ── unauthenticated ───────────────────────────────────────────────────────────
test('unauthenticated POST /api/sales/attachments → 401', function () {
$this->postJson('/api/sales/attachments', ['login' => 'test@example.com'])
->assertUnauthorized();
});
test('unauthenticated GET /api/sales/attachments → 401', function () {
$this->getJson('/api/sales/attachments')
->assertUnauthorized();
});
// ── строгое разделение ролей начальника (18.07.2026) ──────────────────────────
test('на личном экране начальника приходят СВОИ заявки, а не очередь отдела', function () {
Mail::fake();
$manager = apiManager();
$head = apiHead();
SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => 'chuzhaya@example.com',
'status' => 'pending',
]);
SalesAttachmentRequest::create([
'sales_user_id' => $head->id,
'login_input' => 'moya@example.com',
'status' => 'pending',
]);
// Без scope — форма «Привязать клиента» (раздел МЕНЕДЖЕР): формат менеджера.
$mine = $this->actingAs($head, 'sales')->getJson('/api/sales/attachments')->assertOk();
expect($mine->json('pending'))->toBeNull();
$logins = array_column($mine->json('data'), 'login_input');
expect($logins)->toContain('moya@example.com')->not->toContain('chuzhaya@example.com');
// scope=department — экран «Заявки на привязку» (раздел НАЧАЛЬНИК).
$queue = $this->actingAs($head, 'sales')
->getJson('/api/sales/attachments?scope=department')->assertOk();
expect(array_column($queue->json('pending'), 'login_input'))
->toContain('chuzhaya@example.com');
});
test('менеджер не получает очередь отдела через ?scope=department', function () {
Mail::fake();
$manager = apiManager();
$other = apiManager();
SalesAttachmentRequest::create([
'sales_user_id' => $other->id,
'login_input' => 'ne-moya@example.com',
'status' => 'pending',
]);
$res = $this->actingAs($manager, 'sales')
->getJson('/api/sales/attachments?scope=department')->assertOk();
expect($res->json('pending'))->toBeNull();
expect(array_column($res->json('data'), 'login_input'))->not->toContain('ne-moya@example.com');
});