339e8ea53b
Закрыт TODO (c) из v1.50: backend-эндпоинт для списка сделок и его
интеграция в DealsView/KanbanView вместо статичного MOCK_DEALS.
Backend (DealController::index):
- Query-params: tenant_id (required, 422/404), status_in[] (whereIn),
project_id, manager_id, search (ILIKE по phone+contact_name),
limit clamp [1..500] default 100, offset default 0.
- ORDER BY received_at DESC, id DESC. Eager-load project + manager.
- RLS-обёртка SET LOCAL app.current_tenant_id + defense-in-depth
where(tenant_id) — на тестах через postgres superuser RLS обходится
BYPASSRLS, app-фильтр гарантирует изоляцию.
- Ответ: {deals: [...], total, limit, offset}; manager_name/initials
форматируются через ManagerController::formatName/formatInitials.
Pest +12 (DealIndexTest):
- 422/404, пустой список, relations (project_name+manager_name+initials),
RLS-изоляция, ORDER BY, status_in[], project_id, manager_id, search
ILIKE, limit+offset, manager=null edge case.
Frontend:
- api/deals.ts::listDeals — типизированный helper c ApiDeal/ListDeals*.
- composables/dealsApiMapper.ts::mapApiDeal — converter ApiDeal→MockDeal:
contact_name fallback на phone, manager.name='Не назначен' /
initials='—' при null, project='—' при null, cost=0,
receivedMinutesAgo=max(0, …) от clock-skew.
- DealsView/KanbanView: onMounted(loadDeals) async-вызывает listDeals
если auth.user.tenant_id, на success replace через splice, на fail
fetchError=true + v-alert warning, MOCK_DEALS как fallback.
Vitest +14:
- dealsApiMapper.spec.ts (8): 1:1, fallback'и, edge cases.
- DealsListIntegration.spec.ts (6): без tenant_id — НЕ вызывает API,
с tenant_id — replace state, reject → fetchError + alert + fallback;
для DealsView и KanbanView.
PHPStan baseline регенерирован. cspell-glossary +ILIKE +DTO.
Регресс:
- Lint+type-check+format passed.
- Vitest 261/261 за 19.62 сек (+14 от 247).
- Vite build 989 ms.
- Pint + PHPStan passed.
- Pest 186/186 за 22 сек (+12 от 174, 742 assertions).
Реестр v1.59→v1.60 / CLAUDE.md v1.50→v1.51.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
7.7 KiB
PHP
186 lines
7.7 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), 422/404, пагинацию (limit/offset).
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->otherTenant = Tenant::factory()->create();
|
||
|
||
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 возвращает 422 без tenant_id', function () {
|
||
$this->getJson('/api/deals')->assertStatus(422);
|
||
});
|
||
|
||
test('GET /api/deals возвращает 404 для unknown tenant_id', function () {
|
||
$this->getJson('/api/deals?tenant_id=999999')->assertStatus(404);
|
||
});
|
||
|
||
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
|
||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||
|
||
$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?tenant_id='.$this->tenant->id);
|
||
|
||
$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?tenant_id='.$this->tenant->id);
|
||
|
||
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?tenant_id='.$this->tenant->id);
|
||
|
||
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' => 'paid']);
|
||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
|
||
|
||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&status_in[]=new&status_in[]=paid');
|
||
|
||
expect($r->json('total'))->toBe(2);
|
||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||
expect($statuses)->toBe(['new', 'paid']);
|
||
});
|
||
|
||
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?tenant_id='.$this->tenant->id.'&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?tenant_id='.$this->tenant->id.'&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?tenant_id='.$this->tenant->id.'&search=Соколова')
|
||
->json('total'))->toBe(1);
|
||
|
||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=903')
|
||
->json('total'))->toBe(1);
|
||
|
||
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&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?tenant_id='.$this->tenant->id.'&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 возвращает 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?tenant_id='.$this->tenant->id);
|
||
|
||
expect($r->json('deals.0.manager_id'))->toBeNull();
|
||
expect($r->json('deals.0.manager_name'))->toBeNull();
|
||
expect($r->json('deals.0.manager_initials'))->toBeNull();
|
||
});
|