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:
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.5–0.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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user