Files
portal/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php
T
Дмитрий b5849bbd2a fix(projects): cyrillic ILIKE via PG ICU + clearable workaround
Корень: dev-БД `liderra` создавалась с LC_CTYPE=C — lower()/upper() не
делает case-folding для кириллицы, `ILIKE '%сп%'` на «Окна СПб» = 0 строк.
Test-БД с Russian_Russia.1251 маскировала проблему.

Системный fix: dev-БД пересоздана через `LOCALE_PROVIDER icu ICU_LOCALE 'und'`
(PG 16+ ICU collation, кросс-платформенно). Точечный COLLATE-workaround не
понадобился — все 5 ILIKE-endpoint'ов теперь работают с кириллицей без
правки кода. CTO-20 закрыт в реестре v1.81; команда CREATE DATABASE с ICU
зафиксирована для prod-deploy.

Сопутствующее:
- ProjectsView clearable: workaround `::after content '✕'` + видимость
  через `.v-field--dirty` (mdi-* font не подключён в проекте — CTO-19
  заведён в реестре).
- LookupsTest: удалён stale case `GET /api/projects?tenant_id=N`,
  заменённый auth:sanctum-роутом в Plan 5.
- Pest +1 регрессионный тест (`search is case-insensitive for Cyrillic`)
  в ProjectsListShowTest, 10/10 / 37 assertions.
- phpstan-baseline регенерирован (3 actingAs + удалённый case).
- cspell-words: +Регистронезависимый, +und.
- app/.backups/ в gitignore.

Verify:
- Pest --parallel: 742 passed / 1 flaky error (CsvReconcileJobTest cache
  race, в изоляции 2/2 PASS) / 3 skipped.
- Browser: «сп» и «окн» возвращают «Окна СПб».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:25:25 +03:00

159 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
it('returns paginated list of active projects for current tenant', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->count(3)->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com']);
$response = $this->actingAs($user)->getJson('/api/projects');
$response->assertOk();
$response->assertJsonStructure([
'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target',
'delivered_today', 'is_active', 'archived_at', 'sync_status']],
'meta' => ['current_page', 'per_page', 'total'],
]);
expect($response->json('meta.total'))->toBe(3);
});
it('filters list by signal_type', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com']);
Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '+79001234567']);
$response = $this->actingAs($user)->getJson('/api/projects?signal_type=site');
expect($response->json('meta.total'))->toBe(1);
});
it('isolates projects per tenant (RLS)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->create(['tenant_id' => $tenantA->id]);
Project::factory()->count(2)->create(['tenant_id' => $tenantA->id]);
Project::factory()->count(5)->create(['tenant_id' => $tenantB->id]);
$response = $this->actingAs($userA)->getJson('/api/projects');
expect($response->json('meta.total'))->toBe(2);
});
it('excludes archived projects by default', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
$response = $this->actingAs($user)->getJson('/api/projects');
expect($response->json('meta.total'))->toBe(1);
});
it('returns archived when status=archived requested', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
$response = $this->actingAs($user)->getJson('/api/projects?status=archived');
expect($response->json('meta.total'))->toBe(1);
});
it('returns batch fetch by ids without pagination', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$projects = Project::factory()->count(3)->create(['tenant_id' => $tenant->id]);
$ids = $projects->pluck('id')->take(2)->implode(',');
$response = $this->actingAs($user)->getJson("/api/projects?ids={$ids}");
expect(count($response->json('data')))->toBe(2);
});
it('show returns project with supplier_links array', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
$response->assertOk();
$response->assertJsonStructure(['data' => ['id', 'name', 'supplier_links']]);
});
it('?ids batch filters out projects from foreign tenants silently', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->create(['tenant_id' => $tenantA->id]);
$ownProject = Project::factory()->create([
'tenant_id' => $tenantA->id,
'signal_type' => 'site',
'signal_identifier' => 'own.ru',
]);
$foreignProject = Project::factory()->create([
'tenant_id' => $tenantB->id,
'signal_type' => 'site',
'signal_identifier' => 'foreign.ru',
]);
$response = $this->actingAs($userA)->getJson(
"/api/projects?ids={$ownProject->id},{$foreignProject->id}"
);
$response->assertOk();
$data = $response->json('data');
expect(count($data))->toBe(1);
expect($data[0]['id'])->toBe($ownProject->id);
});
it('search is case-insensitive for Cyrillic substrings', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Окна СПб (сайт)',
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Натяжные потолки',
'signal_type' => 'call',
'signal_identifier' => '+79001112233',
]);
$lower = $this->actingAs($user)->getJson('/api/projects?search=сп');
expect($lower->json('meta.total'))->toBe(1);
expect($lower->json('data.0.name'))->toBe('Окна СПб (сайт)');
$upper = $this->actingAs($user)->getJson('/api/projects?search=СП');
expect($upper->json('meta.total'))->toBe(1);
$partial = $this->actingAs($user)->getJson('/api/projects?search=окн');
expect($partial->json('meta.total'))->toBe(1);
});
it('show returns 200 for archived project (read access preserved)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'archived_at' => now(),
'signal_type' => 'site',
'signal_identifier' => 'archived.ru',
]);
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
$response->assertOk();
expect($response->json('data.id'))->toBe($project->id);
expect($response->json('data.archived_at'))->not->toBeNull();
});