Files
portal/app/tests/Feature/Sales/SalesOwnershipScopeTest.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

226 lines
7.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
/**
* TDD: трейт ScopesSalesOwnership.
*
* Проверяет логику ограничения ownership:
* - Менеджер видит только своих клиентов (tenant_ids из sales_client_assignments).
* - Начальник (role=head) на личных экранах видит ТОЛЬКО свои привязки;
* весь отдел — через visibleTenantIds + ?scope=department (§23).
*
* Изоляция: DatabaseTransactions — каждый тест откатывается.
* Используем DEFAULT connection (pgsql → liderra_testing).
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
*/
uses(DatabaseTransactions::class);
/**
* Зонд для тестирования трейта без реального контроллера.
*/
class OwnershipProbe
{
use ScopesSalesOwnership;
public function __construct(private SalesUser $u) {}
/** @return list<int> */
public function ids(): array
{
return $this->ownedTenantIds($this->u);
}
/** @return list<int>|null */
public function visible(Request $request): ?array
{
return $this->visibleTenantIds($this->u, $request);
}
public function scoped(Builder $query): Builder
{
return $this->scopeByOwnership($query, $this->u);
}
}
// ── helpers ────────────────────────────────────────────────────────────────
function makeSalesUserOwn(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => ucfirst($role).' '.uniqid(),
'email' => $role.uniqid().'@scope.local',
'password' => Hash::make('secret'),
'role' => $role,
]);
}
// ── 1. ownedTenantIds ──────────────────────────────────────────────────────
test('ownedTenantIds для менеджера возвращает список его tenant_id', function () {
$manager = makeSalesUserOwn('manager');
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t1->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t2->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
$probe = new OwnershipProbe($manager);
$ids = $probe->ids();
expect($ids)->toBeArray()
->toEqualCanonicalizing([$t1->id, $t2->id]);
});
test('ownedTenantIds для head возвращает ЕГО ЛИЧНЫЕ привязки, а не весь отдел', function () {
$head = makeSalesUserOwn('head');
$manager = makeSalesUserOwn('manager');
$mine = Tenant::factory()->create();
$his = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $head->id, 'tenant_id' => $mine->id,
'tariff_params' => [], 'assigned_at' => now(),
]);
SalesClientAssignment::create([
'sales_user_id' => $manager->id, 'tenant_id' => $his->id,
'tariff_params' => [], 'assigned_at' => now(),
]);
$ids = (new OwnershipProbe($head))->ids();
expect($ids)->toContain($mine->id)->not->toContain($his->id);
});
test('visibleTenantIds: весь отдел только начальнику и только по scope=department', function () {
$head = makeSalesUserOwn('head');
$manager = makeSalesUserOwn('manager');
$probe = fn (SalesUser $u, string $qs) => (new OwnershipProbe($u))
->visible(Request::create('/api/sales/clients'.$qs));
// Начальник без scope — только личное (у него привязок нет → пусто).
expect($probe($head, ''))->toBe([]);
// Начальник с scope=department — без ограничения.
expect($probe($head, '?scope=department'))->toBeNull();
// Менеджеру scope не помогает.
expect($probe($manager, '?scope=department'))->toBe([]);
});
test('ownedTenantIds для менеджера без клиентов возвращает пустой массив', function () {
$manager = makeSalesUserOwn('manager');
$probe = new OwnershipProbe($manager);
$ids = $probe->ids();
expect($ids)->toBeArray()->toBeEmpty();
});
// ── 2. scopeByOwnership ────────────────────────────────────────────────────
test('scopeByOwnership для менеджера ограничивает запрос его tenant_id', function () {
$manager = makeSalesUserOwn('manager');
$other = makeSalesUserOwn('manager');
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$t3 = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t1->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t2->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
// t3 принадлежит другому менеджеру
SalesClientAssignment::create([
'sales_user_id' => $other->id,
'tenant_id' => $t3->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
$probe = new OwnershipProbe($manager);
$results = $probe->scoped(SalesClientAssignment::query())->get();
$tenantIds = $results->pluck('tenant_id')->sort()->values()->all();
expect($tenantIds)->toEqualCanonicalizing([$t1->id, $t2->id]);
});
test('scopeByOwnership ограничивает и начальника — только его привязки', function () {
$head = makeSalesUserOwn('head');
$manager = makeSalesUserOwn('manager');
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t1->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $t2->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
$probe = new OwnershipProbe($head);
$results = $probe->scoped(SalesClientAssignment::query())->get();
// У начальника своих привязок нет → чужие записи менеджера не видны.
expect($results->pluck('tenant_id')->all())
->not->toContain($t1->id)
->not->toContain($t2->id);
});
test('scopeByOwnership для менеджера без клиентов возвращает пустую коллекцию', function () {
$manager = makeSalesUserOwn('manager');
// Создаём другого менеджера с клиентом, чтобы таблица не была пустой
$other = makeSalesUserOwn('manager');
$t1 = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $other->id,
'tenant_id' => $t1->id,
'tariff_params' => [],
'assigned_at' => now(),
]);
$probe = new OwnershipProbe($manager);
$results = $probe->scoped(SalesClientAssignment::query())->get();
expect($results)->toBeEmpty();
});