372668ad41
Task 0.4: middleware EnsureSalesUser (гейт зоны /api/sales — user('sales') активен, иначе 401, alias 'sales-portal'). Трейт ScopesSalesOwnership: менеджер видит только свои tenant_id из sales_client_assignments, начальник (head) — всех (null=без ограничения). Тест SalesOwnershipScopeTest 6/6, всего sales 18/18. Larastan чистый. Один эскейп на сессию.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
6.3 KiB
PHP
188 lines
6.3 KiB
PHP
<?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\Support\Facades\Hash;
|
|
|
|
/**
|
|
* TDD: трейт ScopesSalesOwnership.
|
|
*
|
|
* Проверяет логику ограничения ownership:
|
|
* - Менеджер видит только своих клиентов (tenant_ids из sales_client_assignments).
|
|
* - Начальник (role=head) видит всех — ограничение не применяется (null).
|
|
*
|
|
* Изоляция: 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>|null */
|
|
public function ids(): ?array
|
|
{
|
|
return $this->ownedTenantIds($this->u);
|
|
}
|
|
|
|
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 возвращает null (нет ограничения)', function () {
|
|
$head = makeSalesUserOwn('head');
|
|
|
|
$probe = new OwnershipProbe($head);
|
|
|
|
expect($probe->ids())->toBeNull();
|
|
});
|
|
|
|
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 для head не ограничивает запрос (видит всех)', 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();
|
|
|
|
// Head видит все записи (в контексте транзакции — минимум 2 наши)
|
|
expect($results->count())->toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
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();
|
|
});
|