tenant = Tenant::factory()->create([ 'balance_leads' => 100, ]); }); test('POST /api/deals создаёт сделку с manual source + project firstOrCreate', function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'Окна Москва', 'phone' => '+7 (999) 123-45-67', 'contact_name' => 'Тест Тестов', 'status' => 'new', ]); $r->assertStatus(201); expect($r->json('deal.id'))->toBeInt(); expect($r->json('deal.tenant_id'))->toBe($this->tenant->id); expect($r->json('deal.phone'))->toBe('+7 (999) 123-45-67'); expect($r->json('deal.status'))->toBe('new'); $dealId = $r->json('deal.id'); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $deal = Deal::query()->where('id', $dealId)->first(); expect($deal)->not->toBeNull(); expect($deal->source_crm_id)->toBeNull(); // manual expect($deal->contact_name)->toBe('Тест Тестов'); // Project создан с type='manual' $project = Project::find($r->json('deal.project_id')); expect($project->name)->toBe('Окна Москва'); expect($project->type)->toBe('manual'); }); test('POST /api/deals использует существующий project (не дублирует)', function () { DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $existing = Project::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Натяжные потолки', 'type' => 'webhook', ]); $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'Натяжные потолки', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(201); expect($r->json('deal.project_id'))->toBe($existing->id); // Проверяем что НЕТ нового project'а с таким же name $count = Project::where('tenant_id', $this->tenant->id) ->where('name', 'Натяжные потолки') ->count(); expect($count)->toBe(1); }); test('POST /api/deals пишет ActivityLog с context.source=manual', function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(201); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $log = ActivityLog::where('deal_id', $r->json('deal.id'))->first(); expect($log)->not->toBeNull(); expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED); expect($log->context)->toBe(['source' => 'manual']); }); test('POST /api/deals 422 без обязательных полей', function () { $r = $this->postJson('/api/deals', []); $r->assertStatus(422); expect($r->json('errors'))->toHaveKeys(['tenant_id', 'project_name', 'phone']); }); test('POST /api/deals 404 при unknown tenant_id', function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => 999999, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(404); }); test('POST /api/deals дефолтный status = new если не передан', function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(201); expect($r->json('deal.status'))->toBe('new'); }); test('POST /api/deals с manager_id → assigned_at = NOW()', function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', 'manager_id' => 42, // FK не проверяется (manager_id без FK) ]); $r->assertStatus(201); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $deal = Deal::where('id', $r->json('deal.id'))->first(); expect($deal->manager_id)->toBe(42); expect($deal->assigned_at)->not->toBeNull(); }); test('POST /api/deals manual НЕ списывает баланс tenant\'а', function () { $balanceBefore = $this->tenant->balance_leads; $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', ])->assertStatus(201); $this->tenant->refresh(); expect($this->tenant->balance_leads)->toBe($balanceBefore); }); test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () { // Создаём 2 сделки через store endpoint (получаем реальные id). $r1 = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 111-11-11', 'contact_name' => 'Алиса', ])->json('deal'); $r2 = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 222-22-22', 'contact_name' => 'Боб', ])->json('deal'); $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [$r1['id'], $r2['id']], ]); $r->assertStatus(200); expect($r->headers->get('Content-Type'))->toContain('text/csv'); expect($r->headers->get('Content-Disposition'))->toContain('deals_export_'); $body = $r->getContent(); // BOM первый символ expect($body)->toStartWith("\u{FEFF}"); // Headers строка expect($body)->toContain('ID;Имя;Телефон;Статус'); // Контент сделок expect($body)->toContain('Алиса'); expect($body)->toContain('Боб'); expect($body)->toContain('+7 (999) 111-11-11'); }); test('POST /api/deals/export 422 без ids', function () { $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, ]); $r->assertStatus(422); expect($r->json('errors'))->toHaveKey('ids'); }); test('POST /api/deals/export 404 unknown tenant', function () { $r = $this->postJson('/api/deals/export', [ 'tenant_id' => 999999, 'ids' => [1, 2, 3], ]); $r->assertStatus(404); }); test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () { // Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её. $a = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 111-11-11', 'contact_name' => 'Алиса', ])->json('deal'); $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 222-22-22', 'contact_name' => 'Боб', ])->json('deal'); $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [$a['id']], ]); $r->assertStatus(200); expect($r->getContent())->toContain('Алиса'); expect($r->getContent())->not->toContain('Боб'); }); // NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно // через testing_rls_user (NOLOGIN role без BYPASSRLS) — см. // `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres // superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive.