tenant = Tenant::factory()->create(); $this->otherTenant = Tenant::factory()->create(); $this->user = User::factory()->for($this->tenant)->create(); $this->actingAs($this->user); 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} 401 без auth', function () { $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); auth()->logout(); $this->getJson('/api/deals/'.$deal->id)->assertStatus(401); }); test('GET /api/deals/{id} 404 если сделка не существует', function () { $this->getJson('/api/deals/999999')->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(); // Запрашиваем чужую сделку — RLS+app-фильтр скрывают. $this->getJson('/api/deals/'.$foreign->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); $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' => 'won', 'source' => 'manual'], 'created_at' => now()->subMinutes(5), ]); $r = $this->getJson('/api/deals/'.$deal->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' => 'won']); 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); $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); expect($r->json('events'))->toHaveCount(50); }); /* --------------------------------------------------------------------- * 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('КРЕДИТ'); });