Files
portal/app/tests/Feature/DealShowTest.php
T
Дмитрий cba76c5d18 phase2(deal-show): GET /api/deals/{id} + DealDetailDrawer на реальный ActivityLog
Закрывает gap «timeline в drawer'е показывает hard-coded MOCK_EVENTS» —
теперь drawer fetch'ит реальные activity-events на open из tenant-filtered
activity_log. Без tenant_id — fallback на MOCK_EVENTS как раньше.

Backend (DealController::show):
- GET /api/deals/{id}?tenant_id={id} — возвращает {deal, events}.
- Deal extended (project_name, manager_name/initials, comment, assigned_at).
- Events — последние 50 записей activity_log по (tenant_id, deal_id)
  ORDER BY created_at DESC, с актором (user через belongsTo).
- RLS-обёртка + defense-in-depth where(tenant_id) — 404 если чужая.

Pest +8 (DealShowTest):
- 422/404 базовые / 404 чужая сделка / deal-relations / events ORDER BY +
  actor + actor=null для system-event / RLS+app-фильтр изоляция событий /
  лимит 50 событий.

Frontend:
- api/deals.ts::getDeal — типизированный helper c ApiDealEvent/Detail/Response.
- composables/dealsApiMapper.ts::mapApiDealEvent — converter ApiDealEvent →
  DealEvent: clamp event-slug на known types с fallback на 'deal.viewed';
  detail зависит от type (status_changed: 'from → to'; created: source;
  остальные: JSON-сводка context).
- DealDetailDrawer: optional tenantId prop, watch([open, deal.id, tenantId])
  с immediate=true → loadEvents() на open. Reject → eventsFetchError +
  v-alert warning + MOCK_EVENTS fallback.
- DealsView/KanbanView передают :tenant-id="auth.user?.tenant_id".

Vitest +4 (DealDetailDrawerApi.spec.ts):
- Без tenantId — getDeal не вызывается + MOCK_EVENTS видны.
- С tenantId — getDeal + events замещены + 'new → paid' виден.
- reject → fetchError + alert + MOCK_EVENTS fallback.
- open=false → getDeal не вызывается.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 273/273 за 20.76 сек (+4 от 269).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 205/205 за 24.19 сек (+8 от 197, 812 assertions).

Реестр v1.62→v1.63 / CLAUDE.md v1.53→v1.54.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:21:50 +03:00

166 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
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/{id} — детали сделки + recent activity events для
* DealDetailDrawer.
*/
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->manager = User::factory()->for($this->tenant)->create([
'first_name' => 'Иван',
'last_name' => 'Петров',
'email' => 'ivan@example.test',
]);
});
test('GET /api/deals/{id} 422 без tenant_id', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->getJson('/api/deals/'.$deal->id)->assertStatus(422);
});
test('GET /api/deals/{id} 404 для unknown tenant', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->getJson('/api/deals/'.$deal->id.'?tenant_id=999999')->assertStatus(404);
});
test('GET /api/deals/{id} 404 если сделка не существует', function () {
$this->getJson('/api/deals/999999?tenant_id='.$this->tenant->id)->assertStatus(404);
});
test('GET /api/deals/{id} 404 если сделка чужого tenant\'а (defense-in-depth)', 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();
// Запрашиваем чужую сделку с нашим tenant_id — RLS+app-фильтр скрывают.
$this->getJson('/api/deals/'.$foreign->id.'?tenant_id='.$this->tenant->id)
->assertStatus(404);
});
test('GET /api/deals/{id} возвращает сделку с relations', function () {
$deal = Deal::factory()
->for($this->tenant)
->for($this->project)
->create([
'phone' => '+7 (999) 100-00-00',
'contact_name' => 'Анна С.',
'status' => 'new',
'manager_id' => $this->manager->id,
'comment' => 'Заметка менеджера',
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
expect($r->json('deal.id'))->toBe($deal->id);
expect($r->json('deal.phone'))->toBe('+7 (999) 100-00-00');
expect($r->json('deal.contact_name'))->toBe('Анна С.');
expect($r->json('deal.comment'))->toBe('Заметка менеджера');
expect($r->json('deal.status'))->toBe('new');
expect($r->json('deal.project_name'))->toBe('Окна Москва');
expect($r->json('deal.manager_name'))->toBe('Иван П.');
expect($r->json('deal.manager_initials'))->toBe('ИП');
expect($r->json('deal.received_at'))->toBeString();
});
test('GET /api/deals/{id} возвращает activity events отсортированные по created_at DESC', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
ActivityLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'webhook'],
'created_at' => now()->subMinutes(30),
]);
ActivityLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->manager->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => 'new', 'to' => 'paid', 'source' => 'manual'],
'created_at' => now()->subMinutes(5),
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
$events = $r->json('events');
expect($events)->toHaveCount(2);
// ORDER BY created_at DESC — свежее (status_changed) сверху.
expect($events[0]['event'])->toBe('deal.status_changed');
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'paid']);
expect($events[0]['actor']['name'])->toBe('Иван П.');
expect($events[0]['actor']['initials'])->toBe('ИП');
expect($events[1]['event'])->toBe('deal.created');
expect($events[1]['actor'])->toBeNull(); // user_id=null → system
});
test('GET /api/deals/{id} НЕ возвращает чужие activity events (RLS+app-фильтр)', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
// Пишем event на чужого tenant'а с тем же deal_id (gap if RLS+app-filter не сработает).
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
ActivityLog::create([
'tenant_id' => $this->otherTenant->id,
'user_id' => null,
'deal_id' => $deal->id, // тот же id, но другой tenant
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'leak'],
]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
ActivityLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'webhook'],
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$events = $r->json('events');
expect($events)->toHaveCount(1);
expect($events[0]['context']['source'])->toBe('webhook');
});
test('GET /api/deals/{id} лимит 50 событий', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
foreach (range(1, 60) as $i) {
ActivityLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['n' => $i],
'created_at' => now()->subMinutes(60 - $i),
]);
}
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
expect($r->json('events'))->toHaveCount(50);
});