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 () { DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $manager = User::factory()->for($this->tenant)->create(['is_active' => true]); $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', 'manager_id' => $manager->id, ]); $r->assertStatus(201); $deal = Deal::where('id', $r->json('deal.id'))->first(); expect($deal->manager_id)->toBe($manager->id); 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 manual создаёт SupplierLeadCost если у проекта есть активный supplier', function () { // Создаём supplier + проект + project_suppliers связку. $supplierId = DB::table('suppliers')->insertGetId([ 'code' => 'test_b1_'.bin2hex(random_bytes(3)), 'name' => 'Test Supplier', 'accepts_types' => '{"websites","calls"}', 'cost_rub' => '15.00', 'channel' => 'sites', 'is_active' => true, 'sort_order' => 1, 'quality_score' => 1.00, 'created_at' => now(), ]); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $project = Project::create([ 'tenant_id' => $this->tenant->id, 'name' => 'WithSupplier', 'type' => 'manual', ]); DB::table('project_suppliers')->insert([ 'project_id' => $project->id, 'supplier_id' => $supplierId, 'is_active' => true, 'created_at' => now(), ]); $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'WithSupplier', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(201); // SupplierLeadCost создан со snapshot cost_rub $cost = SupplierLeadCost::query() ->where('deal_id', $r->json('deal.id')) ->first(); expect($cost)->not->toBeNull(); expect($cost->supplier_id)->toBe((int) $supplierId); expect((string) $cost->cost_rub)->toBe('15.00'); expect($cost->supplier_lead_id)->toBeNull(); // manual: нет внешнего id }); test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () { $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'NoSupplier', 'phone' => '+7 (999) 000-00-00', ]); $r->assertStatus(201); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $cost = SupplierLeadCost::query() ->where('deal_id', $r->json('deal.id')) ->count(); expect($cost)->toBe(0); }); 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_'); // Sprint 3 Phase A (O-perf-05): export → StreamedResponse через OpenSpout, // body читается через streamedContent() (см. TestResponse::streamedContent). $body = $r->streamedContent(); // 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); $body = $r->streamedContent(); expect($body)->toContain('Алиса'); expect($body)->not->toContain('Боб'); }); // NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно // через testing_rls_user (NOLOGIN role без BYPASSRLS) — см. // `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres // superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive. test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () { $a = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 111-11-11', 'contact_name' => 'Алиса', ])->json('deal'); $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [$a['id']], 'format' => 'xlsx', ]); $r->assertStatus(200); expect($r->headers->get('Content-Type')) ->toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); expect($r->headers->get('Content-Disposition'))->toContain('.xlsx'); // XLSX = ZIP-archive, начинается с magic bytes "PK\x03\x04". $body = $r->streamedContent(); expect(substr($body, 0, 4))->toBe("PK\x03\x04"); expect(strlen($body))->toBeGreaterThan(2000); // sanity: реальный xlsx > 2 KB }); test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () { $a = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 333-33-33', 'contact_name' => 'Кириллов', ])->json('deal'); $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [$a['id']], 'format' => 'xlsx', ]); $tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_'); file_put_contents($tmp, $r->streamedContent()); $reader = IOFactory::createReader('Xlsx'); $book = $reader->load($tmp); $sheet = $book->getActiveSheet(); expect($sheet->getTitle())->toBe('Сделки'); // Sprint 3 Phase A (O-perf-05): после миграции на OpenSpout streaming, // styled-header cells пишутся как inline-string с RichText. Используем // getFormattedValue() для plain-string сравнения header'ов; для data-cell'ов // OpenSpout продолжает писать обычные shared-strings. expect($sheet->getCell('A1')->getFormattedValue())->toBe('ID'); expect($sheet->getCell('B1')->getFormattedValue())->toBe('Имя'); expect($sheet->getStyle('A1')->getFont()->getBold())->toBeTrue(); // Row 2 — реальная сделка. OpenSpout пишет string-cell'ы как inline-string с // RichText-обёрткой; для plain-string сравнения используем getFormattedValue(). // Numeric cell A2 (ID) — обычный numeric, ->getValue() работает. expect($sheet->getCell('A2')->getValue())->toBe($a['id']); expect($sheet->getCell('B2')->getFormattedValue())->toBe('Кириллов'); expect($sheet->getCell('C2')->getFormattedValue())->toBe('+7 (999) 333-33-33'); unlink($tmp); }); test('POST /api/deals/export 422 на неизвестный format', function () { $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [1], 'format' => 'pdf', ]); $r->assertStatus(422); expect($r->json('errors'))->toHaveKey('format'); }); test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () { $a = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 444-44-44', 'contact_name' => 'Test', ])->json('deal'); $r = $this->postJson('/api/deals/export', [ 'tenant_id' => $this->tenant->id, 'ids' => [$a['id']], ]); $r->assertStatus(200); expect($r->headers->get('Content-Type'))->toContain('text/csv'); expect($r->headers->get('Content-Disposition'))->toContain('.csv'); });