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', '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('returns all projects by default (archive feature removed in v8.27)', 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]); $response = $this->actingAs($user)->getJson('/api/projects'); expect($response->json('meta.total'))->toBe(2); }); it('status=active returns only is_active=true projects', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $response = $this->actingAs($user)->getJson('/api/projects?status=active'); 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 any project by id', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'myproject.ru', ]); $response = $this->actingAs($user)->getJson("/api/projects/{$project->id}"); $response->assertOk(); expect($response->json('data.id'))->toBe($project->id); expect($response->json('data'))->not->toHaveKey('archived_at'); }); // #6 / П14 — селектор per_page 20/50/100/200; серверный максимум 200. it('per_page caps at 200 even if larger value requested', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->count(3)->create(['tenant_id' => $tenant->id]); $r = $this->actingAs($user)->getJson('/api/projects?per_page=500'); $r->assertOk(); expect($r->json('meta.per_page'))->toBe(200); }); it('per_page=100 is accepted as-is (was previously the cap)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->count(2)->create(['tenant_id' => $tenant->id]); $r = $this->actingAs($user)->getJson('/api/projects?per_page=100'); $r->assertOk(); expect($r->json('meta.per_page'))->toBe(100); }); // #7 / П15 — default sort = '-delivered_today' (карточки с активной доставкой за сегодня сверху). it('default sort puts projects with more delivered_today first', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $low = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 2]); $high = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 9]); $mid = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 5]); $r = $this->actingAs($user)->getJson('/api/projects'); $r->assertOk(); $ids = array_column($r->json('data'), 'id'); expect($ids)->toBe([$high->id, $mid->id, $low->id]); }); it('sort by daily_limit_target ascending works (whitelist)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $small = Project::factory()->create(['tenant_id' => $tenant->id, 'daily_limit_target' => 5]); $big = Project::factory()->create(['tenant_id' => $tenant->id, 'daily_limit_target' => 50]); $r = $this->actingAs($user)->getJson('/api/projects?sort=daily_limit_target'); $ids = array_column($r->json('data'), 'id'); expect($ids[0])->toBe($small->id); expect($ids[1])->toBe($big->id); }); it('unknown sort field falls back to default (-delivered_today)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 1]); $top = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 99]); // ?sort=password — попытка SQL-инъекции / неизвестное поле → silent fallback. $r = $this->actingAs($user)->getJson('/api/projects?sort=password'); $r->assertOk(); $ids = array_column($r->json('data'), 'id'); expect($ids[0])->toBe($top->id); }); // #7 / П15 — фильтр по региону (subject code 1..89). Проект под фильтр попадает, // если regions[] содержит код ИЛИ regions пустой/NULL (= вся РФ). it('region filter keeps projects targeting that subject and projects with empty regions (=all RF)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $mskOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [77]]); $spbOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [78]]); $mskSpb = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [77, 78]]); $allRf = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => []]); $r = $this->actingAs($user)->getJson('/api/projects?region=77'); $r->assertOk(); $ids = array_column($r->json('data'), 'id'); expect($ids)->toContain($mskOnly->id); expect($ids)->toContain($mskSpb->id); expect($ids)->toContain($allRf->id); expect($ids)->not->toContain($spbOnly->id); }); it('region filter outside 1..89 range is silently ignored (returns all)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->count(3)->create(['tenant_id' => $tenant->id]); $r = $this->actingAs($user)->getJson('/api/projects?region=999'); expect($r->json('meta.total'))->toBe(3); }); // #7 / П15 — фильтр по дню недели приёма (битмаска). it('delivery_day filter keeps only projects whose mask covers that day', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); // 127 = все 7 дней, 31 = Пн-Пт (биты 0..4), 0 не валиден на CHECK constraint'е → используем 1 (только Пн). $allDays = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 127]); $weekdays = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 31]); $mondayOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 1]); // День Сб (index 5, bit 32). Должны попасть только проекты с битом 32 в маске = allDays. $r = $this->actingAs($user)->getJson('/api/projects?delivery_day=5'); $r->assertOk(); $ids = array_column($r->json('data'), 'id'); expect($ids)->toContain($allDays->id); expect($ids)->not->toContain($weekdays->id); expect($ids)->not->toContain($mondayOnly->id); }); it('delivery_day=0 (Monday) filter does not get treated as "no filter"', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $mon = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 1]); // только Пн (бит 0) $tueOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 2]); // только Вт (бит 1) $r = $this->actingAs($user)->getJson('/api/projects?delivery_day=0'); $r->assertOk(); $ids = array_column($r->json('data'), 'id'); expect($ids)->toContain($mon->id); expect($ids)->not->toContain($tueOnly->id); });