219 lines
7.8 KiB
PHP
219 lines
7.8 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use App\Models\ActivityLog;
|
|||
|
|
use App\Models\Deal;
|
|||
|
|
use App\Models\Project;
|
|||
|
|
use App\Models\Tenant;
|
|||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
|
|||
|
|
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 () {
|
|||
|
|
$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.
|