Files
portal/app/tests/Feature/DealCreateTest.php
T
Дмитрий 86dc930915 refactor(backend): Sprint 3 Phase A — DealController split + N+1 fix + export streaming
Sprint 3 Phase A. Закрытие audit O-refactor-01 + O-perf-01 + O-perf-05.

O-refactor-01: DealController (802 строки) → split на 3 контроллера
по ответственности:
  * DealController (466 строк) — single-resource CRUD (index/show/store/update).
  * DealBulkActionController (264 строки, новый) — bulk transition/destroy/restore.
  * DealExportController (120 строк, новый) — export() с streaming.
API endpoints без изменений; в routes/web.php обновлены только controller@method.

O-perf-01: N+1 в bulk-actions устранён в новом DealBulkActionController.
  * transition: SELECT (id, status) → filter NO-OP → bulk-UPDATE
    whereIn(changed)->update(status) + ActivityLog::insert(массив).
    100 сделок: ~200 SQL → 2 SQL (после SELECT 1 UPDATE + 1 INSERT).
  * destroy/restore: SELECT id'шников живых/удалённых → bulk-UPDATE
    deleted_at + ActivityLog::insert. Аналогично 2 SQL вместо N.
  Defense-in-depth where(tenant_id) сохранён — DealTransitionTest
  «не апдейтит чужие сделки» проходит.

O-perf-05: export() переписан на OpenSpout streaming (composer require
openspout/openspout ^5.3). PhpSpreadsheet строил весь .xlsx в памяти
(10K сделок ≈ 100+ MB RAM). Теперь:
  * Writer::openToFile('php://output') + Row::fromValues + chunkById(500).
  * StreamedResponse → пик памяти O(1) от размера экспорта.
  * CSV: OpenSpout Options(FIELD_DELIMITER=';', SHOULD_ADD_BOM=true) —
    Excel-friendly RU-локаль сохранена.
  * XLSX: getCurrentSheet()->setName('Сделки'), header через
    Row::fromValuesWithStyle с (new Style)->withFontBold(true).

DealCreateTest.php (4 теста про export): getContent() → streamedContent()
для StreamedResponse + getCell()->getFormattedValue() для inline-string
ячеек, которые OpenSpout пишет как RichText (PhpSpreadsheet writer писал
как plain shared-string). Логика тестов и assertion'ы не меняются.

Verification:
  Pest: 416/416 passed (+ 2 skipped), 1388 assertions, 47.5s.
  Larastan: 0 errors above baseline.
  Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:08:20 +03:00

375 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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');
});