5560ebbdfd
Первый Eloquent-слой над schema v8.6 + middleware для RLS-фильтрации HTTP-запросов. Pest 19/19 passed (1672 ms): 4 RLS smoke + 8 model smoke + 5 middleware + 2 default. app/app/Models/ (NEW Tenant.php, Project.php; MODIFIED User.php): - Tenant — saas-уровневая модель (БЕЗ RLS, тенант-родитель). Soft Deletes. hasMany Users, Projects. - User — переписан под нашу схему: password_hash вместо password, first_name/last_name вместо name, override getAuthPassword/Name для Laravel auth-интеграции. Soft Deletes. belongsTo Tenant. - Project — tenant-aware с RLS. belongsTo Tenant. app/database/factories/ (NEW TenantFactory, ProjectFactory; MODIFIED UserFactory): - TenantFactory: уникальный subdomain через Str::random + дефолты (timezone Europe/Moscow, locale ru, is_trial true, api_key_limit 5). - UserFactory: tenant_id через Tenant::factory() chain, email unique через faker, password_hash через Hash::make. - ProjectFactory: tenant_id через factory chain, дефолты под schema.sql (region_mask 255, delivery_days_mask 127, assignment_strategy manual). app/app/Http/Middleware/SetTenantContext.php (NEW, alias `tenant`): - Резолюция tenant_id (приоритет): auth()->user() → subdomain (3+ parts HTTP_HOST) → X-Tenant-Id header (только dev/testing). - Без контекста → 403 Forbidden с явным сообщением. - SET LOCAL app.current_tenant_id внутри транзакции (DB::beginTransaction + SET LOCAL + next() + commit/rollback). PgBouncer-safe (Прил. И Г.1 кейс 2). - Зарегистрирован в bootstrap/app.php через $middleware->alias(). app/tests/Feature/ (NEW TenantModelsTest, SetTenantContextTest): - TenantModelsTest (8 тестов): factories + связи (Tenant→users/projects, User→tenant, Project→tenant) + проверка User::getAuthPassword override. - SetTenantContextTest (5 тестов): 403 без контекста, X-Tenant-Id header, игнор не-числового header, subdomain резолюция через absolute URL, middleware устанавливает app.current_tenant_id для last query. Сопутствующие правки: - app/.gitignore: + _ide_helper_models.php (сгенерированный, как и _ide_helper.php — не в репо) - app/phpstan-baseline.neon: regenerated — Pest dynamic methods ($this->get(), withHeaders()) и factory-return-type mismatch (Larastan v3 не понимает array<string, mixed> vs array<model property of T>) + Request::user/header (Larastan тип hint мисс) + console.php $this в Closure — все в baseline до миграции на typed properties / pest extension - CLAUDE.md §6: Pest 6/6 → 19/19, добавлены модели + middleware - memory project_state.md, MEMORY.md: обновлены под новый этап Не сделано в этой сессии (отложено): - Deal model — composite primary key (id, received_at) + partitioned parent. Эта модель сложнее по архитектуре Eloquent. - ide-helper:models -W -M -N запускался — добавил @mixin IdeHelperX и сгенерил _ide_helper_models.php; но т.к. этот файл gitignored, @mixin строки удалены из моделей (PHPStan не нашёл бы класс на CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
2.8 KiB
PHP
80 lines
2.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
/**
|
|
* Smoke-тесты Eloquent-моделей tenant-уровня (Tenant, User, Project).
|
|
* Проверяют factories, связи и базовые операции CRUD под superuser
|
|
* (без RLS — это отдельно в RlsSmokeTest).
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
test('TenantFactory создаёт тенанта с уникальным subdomain', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
expect($tenant->id)->toBeInt();
|
|
expect($tenant->subdomain)->toStartWith('tenant-');
|
|
expect($tenant->is_trial)->toBeTrue();
|
|
expect($tenant->api_key_limit)->toBe(5);
|
|
});
|
|
|
|
test('UserFactory создаёт пользователя в тенанте через factory()', function () {
|
|
$user = User::factory()->create();
|
|
|
|
expect($user->tenant_id)->toBeInt();
|
|
expect($user->email)->toContain('@');
|
|
expect($user->password_hash)->not->toBeEmpty();
|
|
expect($user->is_active)->toBeTrue();
|
|
});
|
|
|
|
test('ProjectFactory создаёт проект с дефолтным regions/days mask', function () {
|
|
$project = Project::factory()->create();
|
|
|
|
expect($project->tenant_id)->toBeInt();
|
|
expect($project->type)->toBe('webhook');
|
|
expect($project->region_mask)->toBe(255);
|
|
expect($project->delivery_days_mask)->toBe(127);
|
|
expect($project->assignment_strategy)->toBe('manual');
|
|
});
|
|
|
|
test('Tenant->users() возвращает связанных пользователей', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
User::factory()->count(3)->create(['tenant_id' => $tenant->id]);
|
|
|
|
expect($tenant->users)->toHaveCount(3);
|
|
expect($tenant->users->first())->toBeInstanceOf(User::class);
|
|
});
|
|
|
|
test('Tenant->projects() возвращает связанные проекты', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
Project::factory()->count(2)->create(['tenant_id' => $tenant->id]);
|
|
|
|
expect($tenant->projects)->toHaveCount(2);
|
|
});
|
|
|
|
test('User->tenant() возвращает родителя', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
expect($user->tenant->id)->toBe($tenant->id);
|
|
});
|
|
|
|
test('Project->tenant() возвращает родителя', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
expect($project->tenant->id)->toBe($tenant->id);
|
|
});
|
|
|
|
test('User используется password_hash вместо password (override)', function () {
|
|
$user = User::factory()->create();
|
|
|
|
expect($user->getAuthPassword())->toBe($user->password_hash);
|
|
expect($user->getAuthPasswordName())->toBe('password_hash');
|
|
});
|