2026-05-11 18:08:01 +03:00
|
|
|
|
<?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',
|
2026-05-21 08:24:25 +03:00
|
|
|
|
'delivered_today', 'is_active', 'sync_status']],
|
2026-05-11 18:08:01 +03:00
|
|
|
|
'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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 08:24:25 +03:00
|
|
|
|
it('returns all projects by default (archive feature removed in v8.27)', function () {
|
2026-05-11 18:08:01 +03:00
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
|
|
Project::factory()->create(['tenant_id' => $tenant->id]);
|
2026-05-21 08:24:25 +03:00
|
|
|
|
Project::factory()->create(['tenant_id' => $tenant->id]);
|
2026-05-11 18:08:01 +03:00
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($user)->getJson('/api/projects');
|
|
|
|
|
|
|
2026-05-21 08:24:25 +03:00
|
|
|
|
expect($response->json('meta.total'))->toBe(2);
|
2026-05-11 18:08:01 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 08:24:25 +03:00
|
|
|
|
it('status=active returns only is_active=true projects', function () {
|
2026-05-11 18:08:01 +03:00
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
2026-05-21 08:24:25 +03:00
|
|
|
|
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
|
|
|
|
|
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
2026-05-11 18:08:01 +03:00
|
|
|
|
|
2026-05-21 08:24:25 +03:00
|
|
|
|
$response = $this->actingAs($user)->getJson('/api/projects?status=active');
|
2026-05-11 18:08:01 +03:00
|
|
|
|
|
|
|
|
|
|
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']]);
|
|
|
|
|
|
});
|
2026-05-11 18:15:36 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-12 19:25:25 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 08:24:25 +03:00
|
|
|
|
it('show returns 200 for any project by id', function () {
|
2026-05-11 18:15:36 +03:00
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
|
|
$project = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenant->id,
|
|
|
|
|
|
'signal_type' => 'site',
|
2026-05-21 08:24:25 +03:00
|
|
|
|
'signal_identifier' => 'myproject.ru',
|
2026-05-11 18:15:36 +03:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
|
expect($response->json('data.id'))->toBe($project->id);
|
2026-05-21 08:24:25 +03:00
|
|
|
|
expect($response->json('data'))->not->toHaveKey('archived_at');
|
2026-05-11 18:15:36 +03:00
|
|
|
|
});
|
2026-05-22 18:50:04 +03:00
|
|
|
|
|
|
|
|
|
|
// #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);
|
|
|
|
|
|
});
|