cba76c5d18
Закрывает 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>
166 lines
6.6 KiB
PHP
166 lines
6.6 KiB
PHP
<?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);
|
||
});
|