360 lines
15 KiB
PHP
360 lines
15 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Deal;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Тесты GET /api/deals — list-endpoint для DealsView/KanbanView (замена MOCK_DEALS).
|
||
*
|
||
* Покрывает: фильтры (status_in, project_id, manager_id, search), сортировку
|
||
* по received_at DESC, RLS-изоляцию между tenant'ами, относительные поля
|
||
* (project_name, manager_name/initials), 401/404, пагинацию (limit/offset).
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->otherTenant = Tenant::factory()->create();
|
||
|
||
$this->user = User::factory()->for($this->tenant)->create();
|
||
$this->actingAs($this->user);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||
$this->project2 = Project::factory()->for($this->tenant)->create(['name' => 'Натяжные потолки']);
|
||
|
||
$this->manager = User::factory()->for($this->tenant)->create([
|
||
'first_name' => 'Иван',
|
||
'last_name' => 'Петров',
|
||
'email' => 'ivan@example.test',
|
||
]);
|
||
});
|
||
|
||
test('GET /api/deals возвращает 401 без auth', function () {
|
||
auth()->logout();
|
||
$this->getJson('/api/deals')->assertStatus(401);
|
||
});
|
||
|
||
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
$r->assertStatus(200)
|
||
->assertJson(['deals' => [], 'total' => 0, 'limit' => 100, 'offset' => 0]);
|
||
});
|
||
|
||
test('GET /api/deals возвращает сделки tenant\'а с проектом и менеджером', function () {
|
||
$deal = Deal::factory()
|
||
->for($this->tenant)
|
||
->for($this->project)
|
||
->create([
|
||
'phone' => '+7 (999) 111-11-11',
|
||
'contact_name' => 'Анна С.',
|
||
'status' => 'new',
|
||
'manager_id' => $this->manager->id,
|
||
]);
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
$r->assertStatus(200);
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.id'))->toBe($deal->id);
|
||
expect($r->json('deals.0.phone'))->toBe('+7 (999) 111-11-11');
|
||
expect($r->json('deals.0.project_name'))->toBe('Окна Москва');
|
||
expect($r->json('deals.0.manager_name'))->toBe('Иван П.');
|
||
expect($r->json('deals.0.manager_initials'))->toBe('ИП');
|
||
expect($r->json('deals.0.contact_name'))->toBe('Анна С.');
|
||
expect($r->json('deals.0.received_at'))->toBeString();
|
||
});
|
||
|
||
test('GET /api/deals не возвращает сделки чужого tenant\'а (RLS)', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.tenant_id'))->toBe($this->tenant->id);
|
||
});
|
||
|
||
test('GET /api/deals сортирует по received_at DESC', function () {
|
||
$oldest = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'received_at' => now()->subHours(3),
|
||
]);
|
||
$newest = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'received_at' => now()->subMinutes(1),
|
||
]);
|
||
$middle = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'received_at' => now()->subHours(1),
|
||
]);
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
expect($r->json('deals.0.id'))->toBe($newest->id);
|
||
expect($r->json('deals.1.id'))->toBe($middle->id);
|
||
expect($r->json('deals.2.id'))->toBe($oldest->id);
|
||
});
|
||
|
||
test('GET /api/deals фильтрует по status_in[]', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
|
||
|
||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
|
||
|
||
expect($r->json('total'))->toBe(2);
|
||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||
expect($statuses)->toBe(['new', 'won']);
|
||
});
|
||
|
||
test('GET /api/deals фильтрует по project_id', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
Deal::factory()->for($this->tenant)->for($this->project2)->create();
|
||
|
||
$r = $this->getJson('/api/deals?project_id='.$this->project2->id);
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.project_name'))->toBe('Натяжные потолки');
|
||
});
|
||
|
||
test('GET /api/deals фильтрует по manager_id', function () {
|
||
$other = User::factory()->for($this->tenant)->create();
|
||
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $this->manager->id]);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $other->id]);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||
|
||
$r = $this->getJson('/api/deals?manager_id='.$this->manager->id);
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.manager_id'))->toBe($this->manager->id);
|
||
});
|
||
|
||
test('GET /api/deals фильтрует по search (phone + contact_name, ILIKE)', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'phone' => '+7 (999) 111-11-11',
|
||
'contact_name' => 'Анна Соколова',
|
||
]);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'phone' => '+7 (903) 222-22-22',
|
||
'contact_name' => 'Дмитрий Петров',
|
||
]);
|
||
|
||
expect($this->getJson('/api/deals?search=Соколова')
|
||
->json('total'))->toBe(1);
|
||
|
||
expect($this->getJson('/api/deals?search=903')
|
||
->json('total'))->toBe(1);
|
||
|
||
expect($this->getJson('/api/deals?search=сокол') // case-insensitive ILIKE
|
||
->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/deals поддерживает limit + offset', function () {
|
||
foreach (range(1, 5) as $i) {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'received_at' => now()->subMinutes($i),
|
||
]);
|
||
}
|
||
|
||
$r = $this->getJson('/api/deals?limit=2&offset=1');
|
||
|
||
expect($r->json('total'))->toBe(5);
|
||
expect($r->json('limit'))->toBe(2);
|
||
expect($r->json('offset'))->toBe(1);
|
||
expect(count($r->json('deals')))->toBe(2);
|
||
});
|
||
|
||
test('GET /api/deals?only_deleted=true возвращает только soft-deleted', function () {
|
||
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$deleted1 = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$deleted2 = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$deleted1->delete();
|
||
$deleted2->delete();
|
||
|
||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||
|
||
expect($r->json('total'))->toBe(2);
|
||
$ids = collect($r->json('deals'))->pluck('id')->all();
|
||
expect($ids)->not->toContain($alive->id);
|
||
expect($ids)->toContain($deleted1->id);
|
||
expect($ids)->toContain($deleted2->id);
|
||
});
|
||
|
||
test('GET /api/deals (без only_deleted) НЕ возвращает soft-deleted (default)', function () {
|
||
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$deleted->delete();
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.id'))->toBe($alive->id);
|
||
});
|
||
|
||
test('GET /api/deals?only_deleted=true изолирует чужие удалённые сделки', function () {
|
||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
|
||
$foreign->delete();
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||
$own->delete();
|
||
|
||
$r = $this->getJson('/api/deals?only_deleted=true');
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
expect($r->json('deals.0.id'))->toBe($own->id);
|
||
});
|
||
|
||
test('GET /api/deals возвращает manager_name/initials = null если manager_id null', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
expect($r->json('deals.0.manager_id'))->toBeNull();
|
||
expect($r->json('deals.0.manager_name'))->toBeNull();
|
||
expect($r->json('deals.0.manager_initials'))->toBeNull();
|
||
});
|
||
|
||
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
|
||
|
||
test('GET /api/deals с cursor возвращает следующую страницу через keyset', function () {
|
||
// 5 сделок с received_at через 1 минуту (фиксируем порядок).
|
||
$base = now()->subHours(5);
|
||
$ids = [];
|
||
for ($i = 0; $i < 5; $i++) {
|
||
$ids[] = Deal::factory()
|
||
->for($this->tenant)
|
||
->for($this->project)
|
||
->create([
|
||
'status' => 'new',
|
||
'received_at' => $base->copy()->addMinutes($i),
|
||
])->id;
|
||
}
|
||
|
||
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
|
||
$r1 = $this->getJson('/api/deals?limit=2');
|
||
$r1->assertStatus(200);
|
||
expect($r1->json('deals'))->toHaveLength(2);
|
||
expect($r1->json('deals.0.id'))->toBe($ids[4]);
|
||
expect($r1->json('deals.1.id'))->toBe($ids[3]);
|
||
|
||
// Cursor для следующей страницы — base64 от {r:received_at,i:id} последнего элемента.
|
||
$cursor = base64_encode((string) json_encode([
|
||
'r' => $r1->json('deals.1.received_at'),
|
||
'i' => $r1->json('deals.1.id'),
|
||
]));
|
||
|
||
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
|
||
$r2->assertStatus(200);
|
||
expect($r2->json('deals'))->toHaveLength(2);
|
||
expect($r2->json('deals.0.id'))->toBe($ids[2]);
|
||
expect($r2->json('deals.1.id'))->toBe($ids[1]);
|
||
});
|
||
|
||
test('GET /api/deals с невалидным cursor возвращает 422', function () {
|
||
$r = $this->getJson('/api/deals?cursor=not-base64-json');
|
||
$r->assertStatus(422);
|
||
expect($r->json('message'))->toBeString();
|
||
});
|
||
|
||
test('GET /api/deals возвращает next_cursor когда есть ещё страницы', function () {
|
||
$base = now()->subHours(3);
|
||
for ($i = 0; $i < 3; $i++) {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'status' => 'new',
|
||
'received_at' => $base->copy()->addMinutes($i),
|
||
]);
|
||
}
|
||
|
||
$r = $this->getJson('/api/deals?limit=2');
|
||
$r->assertStatus(200);
|
||
expect($r->json('next_cursor'))->toBeString();
|
||
expect($r->json('next_cursor'))->not->toBeEmpty();
|
||
|
||
// Последняя страница: next_cursor = null.
|
||
$cursor = $r->json('next_cursor');
|
||
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
|
||
$r2->assertStatus(200);
|
||
expect($r2->json('next_cursor'))->toBeNull();
|
||
});
|
||
|
||
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||
|
||
$r = $this->getJson('/api/deals?count_only=1');
|
||
|
||
$r->assertStatus(200);
|
||
expect($r->json('total'))->toBe(2);
|
||
expect($r->json('deals'))->toBeNull();
|
||
});
|
||
|
||
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||
|
||
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
|
||
});
|
||
|
||
test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||
|
||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||
$foreignProject = Project::factory()->for($this->otherTenant)->create();
|
||
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
|
||
|
||
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/deals фильтрует по received_from/received_to', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);
|
||
|
||
$r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');
|
||
|
||
expect($r->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/deals received_to включает весь день (конец дня)', function () {
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);
|
||
|
||
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/deals возвращает comment/city/project_signal_type/next_reminder_at', function () {
|
||
$this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
|
||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||
'comment' => 'перезвонить',
|
||
'city' => 'Казань',
|
||
]);
|
||
|
||
$r = $this->getJson('/api/deals');
|
||
|
||
expect($r->json('deals.0.comment'))->toBe('перезвонить');
|
||
expect($r->json('deals.0.city'))->toBe('Казань');
|
||
expect($r->json('deals.0.project_signal_type'))->toBe('call');
|
||
expect($r->json('deals.0'))->toHaveKey('next_reminder_at');
|
||
});
|
||
|
||
test('GET /api/deals возвращает 422 на невалидную received_from', function () {
|
||
$this->getJson('/api/deals?received_from=не-дата')->assertStatus(422);
|
||
});
|
||
|
||
test('GET /api/deals возвращает 422 на невалидную received_to', function () {
|
||
$this->getJson('/api/deals?received_to=garbage')->assertStatus(422);
|
||
});
|