2026-05-09 08:21:50 +03:00
|
|
|
|
<?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();
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$this->user = User::factory()->for($this->tenant)->create();
|
|
|
|
|
|
$this->actingAs($this->user);
|
|
|
|
|
|
|
2026-05-09 08:21:50 +03:00
|
|
|
|
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',
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
test('GET /api/deals/{id} 401 без auth', function () {
|
2026-05-09 08:21:50 +03:00
|
|
|
|
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
2026-05-16 15:14:17 +03:00
|
|
|
|
auth()->logout();
|
|
|
|
|
|
$this->getJson('/api/deals/'.$deal->id)->assertStatus(401);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('GET /api/deals/{id} 404 если сделка не существует', function () {
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$this->getJson('/api/deals/999999')->assertStatus(404);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
// Запрашиваем чужую сделку — RLS+app-фильтр скрывают.
|
|
|
|
|
|
$this->getJson('/api/deals/'.$foreign->id)
|
2026-05-09 08:21:50 +03:00
|
|
|
|
->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' => 'Заметка менеджера',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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,
|
2026-05-17 18:18:00 +03:00
|
|
|
|
'context' => ['from' => 'new', 'to' => 'won', 'source' => 'manual'],
|
2026-05-09 08:21:50 +03:00
|
|
|
|
'created_at' => now()->subMinutes(5),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
2026-05-17 18:18:00 +03:00
|
|
|
|
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'won']);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
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'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
|
|
|
|
|
|
$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),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:14:17 +03:00
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
2026-05-09 08:21:50 +03:00
|
|
|
|
|
|
|
|
|
|
expect($r->json('events'))->toHaveCount(50);
|
|
|
|
|
|
});
|
2026-05-18 15:24:57 +03:00
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------
|
|
|
|
|
|
* 18.05.2026 UX-request: drawer сделки показывает «Тип» + «Источник»
|
|
|
|
|
|
* проекта. Backend отдаёт project_signal_type/identifier/sms_*.
|
|
|
|
|
|
* --------------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
test('GET /api/deals/{id} отдаёт project_signal_identifier/sms_keyword/sms_senders для site-проекта', function () {
|
|
|
|
|
|
$siteProject = Project::factory()->for($this->tenant)->create([
|
|
|
|
|
|
'signal_type' => 'site',
|
|
|
|
|
|
'signal_identifier' => 'krk-finance.ru',
|
|
|
|
|
|
]);
|
|
|
|
|
|
$deal = Deal::factory()->for($this->tenant)->for($siteProject)->create();
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(200);
|
|
|
|
|
|
expect($r->json('deal.project_signal_type'))->toBe('site');
|
|
|
|
|
|
expect($r->json('deal.project_signal_identifier'))->toBe('krk-finance.ru');
|
|
|
|
|
|
expect($r->json('deal.project_sms_keyword'))->toBeNull();
|
|
|
|
|
|
expect($r->json('deal.project_sms_senders'))->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('GET /api/deals/{id} отдаёт sms_senders/sms_keyword для sms-проекта', function () {
|
|
|
|
|
|
$smsProject = Project::factory()->for($this->tenant)->create([
|
|
|
|
|
|
'signal_type' => 'sms',
|
|
|
|
|
|
'signal_identifier' => 'MTS',
|
|
|
|
|
|
'sms_senders' => ['MTS', 'BEELINE'],
|
|
|
|
|
|
'sms_keyword' => 'КРЕДИТ',
|
|
|
|
|
|
]);
|
|
|
|
|
|
$deal = Deal::factory()->for($this->tenant)->for($smsProject)->create();
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->getJson('/api/deals/'.$deal->id);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(200);
|
|
|
|
|
|
expect($r->json('deal.project_signal_type'))->toBe('sms');
|
|
|
|
|
|
expect($r->json('deal.project_sms_senders'))->toBe(['MTS', 'BEELINE']);
|
|
|
|
|
|
expect($r->json('deal.project_sms_keyword'))->toBe('КРЕДИТ');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('GET /api/deals отдаёт те же поля в index payload', function () {
|
|
|
|
|
|
$smsProject = Project::factory()->for($this->tenant)->create([
|
|
|
|
|
|
'signal_type' => 'sms',
|
|
|
|
|
|
'signal_identifier' => 'MTS',
|
|
|
|
|
|
'sms_senders' => ['MTS'],
|
|
|
|
|
|
'sms_keyword' => 'КРЕДИТ',
|
|
|
|
|
|
]);
|
|
|
|
|
|
Deal::factory()->for($this->tenant)->for($smsProject)->create();
|
|
|
|
|
|
|
|
|
|
|
|
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
$r->assertStatus(200);
|
|
|
|
|
|
expect($r->json('deals.0.project_signal_type'))->toBe('sms');
|
|
|
|
|
|
expect($r->json('deals.0.project_signal_identifier'))->toBe('MTS');
|
|
|
|
|
|
expect($r->json('deals.0.project_sms_senders'))->toBe(['MTS']);
|
|
|
|
|
|
expect($r->json('deals.0.project_sms_keyword'))->toBe('КРЕДИТ');
|
|
|
|
|
|
});
|