Files
portal/app/tests/Feature/TenantModelsTest.php
T
Дмитрий 5560ebbdfd phase1(eloquent): Tenant/User/Project models + SetTenantContext middleware
Первый 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>
2026-05-08 14:29:50 +03:00

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');
});