830a652588
Расширяет stages 5/6 (soft-delete + 8-сек undo) до постоянного доступа
к удалённым сделкам через отдельный view-mode.
Backend (DealController::index):
- Новый query-param only_deleted=true.
- withTrashed() + whereNotNull('deleted_at') — обход global scope
SoftDeletes + явный фильтр для NO-OP idempotency.
- Все остальные фильтры применимы и в trash-mode.
Pest +3 (DealIndexTest):
- only_deleted=true → только soft-deleted (alive скрыты).
- Без only_deleted → soft-deleted скрыты (default behavior).
- RLS+app-фильтр изолирует чужие удалённые.
Frontend:
- ListDealsParams.onlyDeleted?: boolean + axios mapping.
- DealsView: trashMode ref + toggleTrashMode (clear selected + reload) +
applyBulkRestoreFromTrash (optimistic remove + bulkRestoreDeals + toast).
- UI changes в trash-mode:
- Заголовок «Сделки» → «Корзина».
- Toggle-btn 'mdi-arrow-left К сделкам' (warning-flat) вместо
'mdi-trash-can-outline Корзина' (outlined).
- Скрыты Экспорт + Новая сделка.
- Скрыт chiprow filter-bar.
- Info-alert «Корзина: показаны удалённые сделки».
- Bulk-bar: только Восстановить (mdi-restore success-tonal) + clear;
status/export/delete скрыты.
Vitest +2 (DealsListIntegration):
- toggleTrashMode → trashMode=true + listDeals с onlyDeleted=true.
- applyBulkRestoreFromTrash → bulkRestoreDeals + remove from state +
toast «Восстановлено 2».
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 321/321 за 19.60 сек (+2 от 319).
- Vite build 1.04 сек.
- Pint + PHPStan passed.
- Pest 269/269 за 29.12 сек (+3 от 266, 1009 assertions).
Реестр v1.72→v1.73 / CLAUDE.md v1.63→v1.64.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
9.6 KiB
PHP
229 lines
9.6 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?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?tenant_id='.$this->tenant->id.'&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?tenant_id='.$this->tenant->id);
|
||
|
||
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?tenant_id='.$this->tenant->id.'&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?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();
|
||
});
|