feat(sales): вход (login/me/logout) + маршруты /api/sales

Task 0.5+0.6: SalesAuthController (login 200/422/403, me, logout) + маршруты /api/sales/auth и зона данных. Порядок middleware admin-db ДО auth:sales. Тест SalesAuthTest 7/7, весь sales-набор 25/25. Logout инвалидирует токен (в тесте Auth::forgetGuards() — артефакт мульти-запросов; в бою каждый запрос свежий). Larastan baseline: Pest false-pos SalesAuthTest. Заодно pint-канон моделей/трейта/SalesModelsTest. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 13:07:47 +03:00
parent 372668ad41
commit c366614fcd
11 changed files with 342 additions and 42 deletions
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Models\SalesUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
/**
* Аутентификация портала отдела продаж.
*
* Все маршруты идут через middleware admin-db (UseAdminConnection),
* который переключает default-соединение на pgsql_admin (crm_admin_user).
* Это необходимо, потому что sales_users и personal_access_tokens доступны
* crm_admin_user, а Sanctum читает токены ДО контроллера в middleware auth:sales.
*
* guard: 'sales' (Sanctum, provider sales_users) см. config/auth.php.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.5)
*/
class SalesAuthController extends Controller
{
/**
* Вход менеджера / руководителя продаж.
*
* Валидация: email (required, email) + password (required, string).
* Ошибки: 422 неверные учётные данные, 403 аккаунт отключён.
* Успех: 200 {token, user: {id, name, email, role}}.
*/
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$user = SalesUser::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json(
['message' => 'Неверный логин или пароль.'],
422
);
}
if (! $user->is_active) {
return response()->json(
['message' => 'Аккаунт отключён, обратитесь к начальнику.'],
403
);
}
$token = $user->createToken('sales')->plainTextToken;
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
],
]);
}
/**
* Текущий авторизованный менеджер.
*
* Guard: auth:sales Sanctum Bearer-токен.
* Возвращает: {id, name, email, role}.
*/
public function me(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
]);
}
/**
* Выход инвалидирует текущий токен.
*
* Guard: auth:sales.
* Возвращает: 200 {message}.
*/
public function logout(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
$user->currentAccessToken()->delete();
return response()->json(['message' => 'Вы вышли.']);
}
}
@@ -51,6 +51,7 @@ trait ScopesSalesOwnership
* чтобы запрос вернул пустую коллекцию.
*
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
+3 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -27,8 +28,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $status
* @property string|null $comment
* @property int|null $decided_by
* @property \Carbon\Carbon|null $decided_at
* @property \Carbon\Carbon $created_at
* @property Carbon|null $decided_at
* @property Carbon $created_at
*/
class SalesAttachmentRequest extends Model
{
+5 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -27,8 +28,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int|null $tariff_id
* @property string|null $tariff_kind
* @property array<string,mixed> $tariff_params
* @property \Carbon\Carbon $assigned_at
* @property \Carbon\Carbon $created_at
* @property Carbon $assigned_at
* @property Carbon $created_at
*/
class SalesClientAssignment extends Model
{
@@ -47,8 +48,8 @@ class SalesClientAssignment extends Model
{
return [
'tariff_params' => 'array',
'assigned_at' => 'datetime',
'created_at' => 'datetime',
'assigned_at' => 'datetime',
'created_at' => 'datetime',
];
}
+4 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -22,10 +23,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $id
* @property int $sales_user_id
* @property string $amount_rub
* @property \Carbon\Carbon $paid_on
* @property Carbon $paid_on
* @property string|null $comment
* @property int $created_by
* @property \Carbon\Carbon $created_at
* @property Carbon $created_at
*/
class SalesPayout extends Model
{
@@ -43,7 +44,7 @@ class SalesPayout extends Model
{
return [
'amount_rub' => 'decimal:2',
'paid_on' => 'date',
'paid_on' => 'date',
'created_at' => 'datetime',
];
}
+2 -2
View File
@@ -36,8 +36,8 @@ class SalesTariff extends Model
protected function casts(): array
{
return [
'params' => 'array',
'is_active' => 'boolean',
'params' => 'array',
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
+3 -3
View File
@@ -54,10 +54,10 @@ class SalesUser extends Authenticatable
protected function casts(): array
{
return [
'is_active' => 'boolean',
'is_active' => 'boolean',
'base_salary_rub' => 'decimal:2',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
+24
View File
@@ -252,6 +252,30 @@ parameters:
count: 1
path: tests/Feature/Sales/SalesGuardTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
+16
View File
@@ -227,6 +227,22 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
});
});
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
// Всё через admin-db (crm_admin_user): и логин, и проверка токена, и cross-tenant
// чтение; каждый запрос данных фильтруется по владению (ScopesSalesOwnership).
// admin-db СТОИТ ПЕРЕД auth:sales (Sanctum читает токены/sales_users под crm_admin_user).
Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () {
Route::post('/login', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'login']);
Route::middleware('auth:sales')->group(function () {
Route::get('/me', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'me']);
Route::post('/logout', [\App\Http\Controllers\Api\Sales\SalesAuthController::class, 'logout']);
});
});
// Зона данных портала (наполняется в Фазах 1–7).
Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () {
// clients, attachments, income, tariffs, payouts, invoices, managers, dashboard
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Tests\Concerns\SharesSupplierPdo;
/**
* TDD: SalesAuthController login / me / logout.
*
* Покрывает Task 0.5 + 0.6 портала продаж:
* - POST /api/sales/auth/login 200 (token + user) / 422 (неверный логин) / 403 (отключён)
* - GET /api/sales/auth/me 401 без токена / 200 с токеном
* - POST /api/sales/auth/logout 200; повторный GET /me 401
*
* Маршруты идут через middleware admin-db (UseAdminConnection), который
* переключает default-соединение на pgsql_admin (crm_admin_user). В тестах
* SharesAdminPdo (глобально в Pest.php) связывает pgsql и pgsql_admin через
* один PDO, чтобы данные, засеянные через pgsql, были видны запросу.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Tasks 0.50.6)
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ─── helpers ────────────────────────────────────────────────────────────────
/**
* Создаёт SalesUser с известным паролем и возвращает [user, plainPassword].
*
* @return array{0: SalesUser, 1: string}
*/
function makeSalesUserWithPassword(bool $active = true): array
{
$plain = 'SecretPass-'.uniqid();
$user = SalesUser::create([
'name' => 'Тест Менеджер '.uniqid(),
'email' => 'salesauth-'.uniqid().'@test.local',
'password' => Hash::make($plain),
'role' => 'manager',
'is_active' => $active,
]);
return [$user, $plain];
}
// ─── login ───────────────────────────────────────────────────────────────────
test('login: верные данные активного пользователя → 200, token и user.role', function () {
[$user, $plain] = makeSalesUserWithPassword();
$response = $this->postJson('/api/sales/auth/login', [
'email' => $user->email,
'password' => $plain,
]);
$response->assertOk();
expect($response->json('token'))->not->toBeNull()->not->toBe('');
expect($response->json('user.role'))->toBe('manager');
expect($response->json('user.email'))->toBe($user->email);
});
test('login: неверный пароль → 422 с сообщением', function () {
[$user] = makeSalesUserWithPassword();
$response = $this->postJson('/api/sales/auth/login', [
'email' => $user->email,
'password' => 'wrong-password-xyz',
]);
$response->assertStatus(422);
expect($response->json('message'))->toBe('Неверный логин или пароль.');
});
test('login: несуществующий email → 422 с сообщением', function () {
$response = $this->postJson('/api/sales/auth/login', [
'email' => 'nonexistent-'.uniqid().'@test.local',
'password' => 'some-password',
]);
$response->assertStatus(422);
expect($response->json('message'))->toBe('Неверный логин или пароль.');
});
test('login: неактивный пользователь с верным паролем → 403', function () {
[$user, $plain] = makeSalesUserWithPassword(active: false);
$response = $this->postJson('/api/sales/auth/login', [
'email' => $user->email,
'password' => $plain,
]);
$response->assertStatus(403);
expect($response->json('message'))->toBe('Аккаунт отключён, обратитесь к начальнику.');
});
// ─── me ──────────────────────────────────────────────────────────────────────
test('me: запрос без токена → 401', function () {
$this->getJson('/api/sales/auth/me')
->assertUnauthorized();
});
test('me: запрос с валидным Bearer-токеном → 200 с данными пользователя', function () {
[$user, $plain] = makeSalesUserWithPassword();
// Получаем токен через логин.
$loginResponse = $this->postJson('/api/sales/auth/login', [
'email' => $user->email,
'password' => $plain,
]);
$loginResponse->assertOk();
$token = $loginResponse->json('token');
// Запрашиваем /me с токеном.
$meResponse = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/sales/auth/me');
$meResponse->assertOk();
expect($meResponse->json('email'))->toBe($user->email);
expect($meResponse->json('role'))->toBe('manager');
});
// ─── logout ──────────────────────────────────────────────────────────────────
test('logout: выход инвалидирует токен, повторный /me → 401', function () {
[$user, $plain] = makeSalesUserWithPassword();
// Создаём токен напрямую (без login endpoint) — стабильнее в тестах.
// Проверка самого login-эндпоинта покрыта первым тестом выше.
$plainToken = $user->createToken('sales')->plainTextToken;
// Выход через endpoint.
$logoutResponse = $this->withHeader('Authorization', 'Bearer '.$plainToken)
->postJson('/api/sales/auth/logout');
$logoutResponse->assertOk();
expect($logoutResponse->json('message'))->toBe('Вы вышли.');
// Сбрасываем кэш guard-инстансов в AuthManager (общий singleton в контейнере).
// Без этого guard('sales') возвращает пользователя, кэшированного из предыдущего
// logout-запроса, даже если токен уже удалён из БД.
Auth::forgetGuards();
// Повторный /me с тем же токеном должен вернуть 401.
$this->withHeader('Authorization', 'Bearer '.$plainToken)
->getJson('/api/sales/auth/me')
->assertUnauthorized();
});
+30 -28
View File
@@ -8,7 +8,9 @@ use App\Models\SalesPayout;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
@@ -28,9 +30,9 @@ uses(DatabaseTransactions::class);
function makeSalesTariff(array $attrs = []): SalesTariff
{
return SalesTariff::create(array_merge([
'name' => 'Test tariff ' . uniqid(),
'kind' => 'topup_step',
'params' => ['step' => 1000, 'bonus' => 100],
'name' => 'Test tariff '.uniqid(),
'kind' => 'topup_step',
'params' => ['step' => 1000, 'bonus' => 100],
'is_active' => true,
], $attrs));
}
@@ -38,10 +40,10 @@ function makeSalesTariff(array $attrs = []): SalesTariff
function makeSalesUser(array $attrs = []): SalesUser
{
return SalesUser::create(array_merge([
'name' => 'Manager ' . uniqid(),
'email' => 'mgr' . uniqid() . '@test.local',
'name' => 'Manager '.uniqid(),
'email' => 'mgr'.uniqid().'@test.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'role' => 'manager',
], $attrs));
}
@@ -65,9 +67,9 @@ test('SalesTariff можно создать и params отдаётся масс
test('SalesUser создаётся с role=manager и current_tariff_id', function () {
$tariff = makeSalesTariff();
$user = makeSalesUser([
'role' => 'manager',
'role' => 'manager',
'current_tariff_id' => $tariff->id,
'base_salary_rub' => '50000.00',
'base_salary_rub' => '50000.00',
]);
expect($user->id)->toBeInt()
@@ -77,7 +79,7 @@ test('SalesUser создаётся с role=manager и current_tariff_id', functi
});
test('SalesUser::isHead() верен для role=head и false для role=manager', function () {
$head = makeSalesUser(['role' => 'head']);
$head = makeSalesUser(['role' => 'head']);
$manager = makeSalesUser(['role' => 'manager']);
expect($head->isHead())->toBeTrue()
@@ -94,33 +96,33 @@ test('SalesUser is_active приходит булевым', function () {
test('SalesClientAssignment связывает пользователя с тенантом и tariff_params — массив', function () {
$tariff = makeSalesTariff(['kind' => 'percent_oborot', 'params' => ['percent' => 5]]);
$user = makeSalesUser(['current_tariff_id' => $tariff->id]);
$user = makeSalesUser(['current_tariff_id' => $tariff->id]);
$tenant = Tenant::factory()->create(); // реальный тенант (FK tenants.id)
$assignment = SalesClientAssignment::create([
'sales_user_id' => $user->id,
'tenant_id' => $tenant->id,
'tariff_id' => $tariff->id,
'tariff_kind' => $tariff->kind,
'tenant_id' => $tenant->id,
'tariff_id' => $tariff->id,
'tariff_kind' => $tariff->kind,
'tariff_params' => ['percent' => 5],
'assigned_at' => now(),
'assigned_at' => now(),
]);
expect($assignment->id)->toBeInt()
->and($assignment->tariff_params)->toBeArray()
->and($assignment->tariff_params['percent'])->toBe(5)
->and($assignment->assigned_at)->toBeInstanceOf(\Carbon\Carbon::class);
->and($assignment->assigned_at)->toBeInstanceOf(Carbon::class);
});
test('SalesUser->assignments() возвращает HasMany-коллекцию', function () {
$user = makeSalesUser();
$user = makeSalesUser();
$tenant = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $user->id,
'tenant_id' => $tenant->id,
'tenant_id' => $tenant->id,
'tariff_params' => [],
'assigned_at' => now(),
'assigned_at' => now(),
]);
$relation = $user->assignments();
@@ -138,8 +140,8 @@ test('SalesAttachmentRequest создаётся со статусом pending',
$req = SalesAttachmentRequest::create([
'sales_user_id' => $user->id,
'login_input' => 'client@example.com',
'status' => 'pending',
'login_input' => 'client@example.com',
'status' => 'pending',
]);
expect($req->id)->toBeInt()
@@ -154,14 +156,14 @@ test('SalesPayout создаётся, amount_rub — decimal, paid_on — date',
$payout = SalesPayout::create([
'sales_user_id' => $user->id,
'amount_rub' => '12500.50',
'paid_on' => today(),
'created_by' => $user->id,
'amount_rub' => '12500.50',
'paid_on' => today(),
'created_by' => $user->id,
]);
expect($payout->id)->toBeInt()
->and((float) $payout->amount_rub)->toBe(12500.50)
->and($payout->paid_on)->toBeInstanceOf(\Carbon\Carbon::class);
->and($payout->paid_on)->toBeInstanceOf(Carbon::class);
});
test('SalesPayout append-only: UPDATE бросает исключение', function () {
@@ -169,11 +171,11 @@ test('SalesPayout append-only: UPDATE бросает исключение', func
$payout = SalesPayout::create([
'sales_user_id' => $user->id,
'amount_rub' => '100.00',
'paid_on' => today(),
'created_by' => $user->id,
'amount_rub' => '100.00',
'paid_on' => today(),
'created_by' => $user->id,
]);
expect(fn () => $payout->update(['amount_rub' => '999.00']))
->toThrow(\Illuminate\Database\QueryException::class);
->toThrow(QueryException::class);
});