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