Files
portal/app/tests/Feature/DealIndexTest.php
T
Дмитрий 1ed82b6b36 fix: сутки по всему порталу считаются с московской полуночи, а не с трёх ночи
Продолжение правки отдела продаж от 02.08: тот же класс ошибки найден ещё
в тринадцати местах. Граница периода, посчитанная по Москве, уходит в базу
надписью без смещения и читается как гринвичская — сутки съезжают на три часа.

У ошибки оказалось ДВА вкуса, и поиск из промта находил только первый:
  1. граница по Москве, отданная как есть (дашборд клиента, списания,
     расход на рекламу, карточка клиента у отдела продаж, сводка админа);
  2. граница ВООБЩЕ без Москвы - now()->startOfDay() и Carbon::today();
     пояс приложения гринвичский, значит день начинался в 03:00 МСК
     (список лидов и выгрузка, биллинг, карточка клиента у админа,
     посетители, ответ бота клиенту, сверка CSV, снимок рекламной кампании).

Правило названо в одном месте - App\Support\MskBoundary: instant() отдаёт
границу мгновением, dayAfter() - первое мгновение после дня (полуинтервал,
чтобы не терять последнюю секунду). Календарные даты и счёт дней НЕ трогали:
там нужен именно московский календарь.

Заодно в списаниях клиента убрана вторая копия условий периода - выгрузка
CSV фильтровала по своей копии, и та уже разъехалась с общей.

Проверка: tests/Feature/NightBoundaryMskTest.php - девять проверок с часами,
замороженными на 00:30 МСК. До правки девять из девяти красные, после - зелёные.
Две из них поначалу проходили и на сломанном коде: в 00:30 МСК гринвичский день
это ещё вчерашний, и ночное событие случайно попадало в окно; добавлено второе
событие "вчера днём", которое обязано остаться за бортом.

Три чужие проверки закрепляли старую ошибку и поправлены:
  - DealIndexTest "конец дня" клал заявку в 23:30 по Гринвичу - по московскому
    календарю это уже 02:30 следующего дня;
  - ClientFactsTest и GuardCardContractTest строят заявки от now() и ночью
    краснеют сами - часы заморожены на 12:00 МСК с возвратом в afterEach.

Статанализ: 0. Форматтер: чисто.
2026-08-02 10:18:45 +03:00

510 lines
22 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\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Тесты GET /api/deals — list-endpoint для DealsView/KanbanView (замена MOCK_DEALS).
*
* Покрывает: фильтры (status_in, project_id, manager_id, search), сортировку
* по received_at DESC, RLS-изоляцию между tenant'ами, относительные поля
* (project_name, manager_name/initials), 401/404, пагинацию (limit/offset).
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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->project2 = 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 возвращает 401 без auth', function () {
auth()->logout();
$this->getJson('/api/deals')->assertStatus(401);
});
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
$r = $this->getJson('/api/deals');
$r->assertStatus(200)
->assertJson(['deals' => [], 'total' => 0, 'limit' => 100, 'offset' => 0]);
});
test('GET /api/deals возвращает сделки tenant\'а с проектом и менеджером', function () {
$deal = Deal::factory()
->for($this->tenant)
->for($this->project)
->create([
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Анна С.',
'status' => 'new',
'manager_id' => $this->manager->id,
]);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.id'))->toBe($deal->id);
expect($r->json('deals.0.phone'))->toBe('+7 (999) 111-11-11');
expect($r->json('deals.0.project_name'))->toBe('Окна Москва');
expect($r->json('deals.0.manager_name'))->toBe('Иван П.');
expect($r->json('deals.0.manager_initials'))->toBe('ИП');
expect($r->json('deals.0.contact_name'))->toBe('Анна С.');
expect($r->json('deals.0.received_at'))->toBeString();
});
test('GET /api/deals не возвращает сделки чужого tenant\'а (RLS)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreignProject = Project::factory()->for($this->otherTenant)->create();
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.tenant_id'))->toBe($this->tenant->id);
});
test('GET /api/deals сортирует по received_at DESC', function () {
$oldest = Deal::factory()->for($this->tenant)->for($this->project)->create([
'received_at' => now()->subHours(3),
]);
$newest = Deal::factory()->for($this->tenant)->for($this->project)->create([
'received_at' => now()->subMinutes(1),
]);
$middle = Deal::factory()->for($this->tenant)->for($this->project)->create([
'received_at' => now()->subHours(1),
]);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.id'))->toBe($newest->id);
expect($r->json('deals.1.id'))->toBe($middle->id);
expect($r->json('deals.2.id'))->toBe($oldest->id);
});
test('GET /api/deals фильтрует по status_in[]', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
expect($r->json('total'))->toBe(2);
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
expect($statuses)->toBe(['new', 'won']);
});
test('GET /api/deals фильтрует по project_id', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create();
Deal::factory()->for($this->tenant)->for($this->project)->create();
Deal::factory()->for($this->tenant)->for($this->project2)->create();
$r = $this->getJson('/api/deals?project_id='.$this->project2->id);
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.project_name'))->toBe('Натяжные потолки');
});
test('GET /api/deals фильтрует по manager_id', function () {
$other = User::factory()->for($this->tenant)->create();
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $this->manager->id]);
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $other->id]);
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
$r = $this->getJson('/api/deals?manager_id='.$this->manager->id);
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.manager_id'))->toBe($this->manager->id);
});
test('GET /api/deals фильтрует по search (phone + contact_name, ILIKE)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create([
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Анна Соколова',
]);
Deal::factory()->for($this->tenant)->for($this->project)->create([
'phone' => '+7 (903) 222-22-22',
'contact_name' => 'Дмитрий Петров',
]);
expect($this->getJson('/api/deals?search=Соколова')
->json('total'))->toBe(1);
expect($this->getJson('/api/deals?search=903')
->json('total'))->toBe(1);
expect($this->getJson('/api/deals?search=сокол') // case-insensitive ILIKE
->json('total'))->toBe(1);
});
test('GET /api/deals поддерживает limit + offset', function () {
foreach (range(1, 5) as $i) {
Deal::factory()->for($this->tenant)->for($this->project)->create([
'received_at' => now()->subMinutes($i),
]);
}
$r = $this->getJson('/api/deals?limit=2&offset=1');
expect($r->json('total'))->toBe(5);
expect($r->json('limit'))->toBe(2);
expect($r->json('offset'))->toBe(1);
expect(count($r->json('deals')))->toBe(2);
});
test('GET /api/deals?only_deleted=true возвращает только soft-deleted', function () {
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted1 = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted2 = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted1->delete();
$deleted2->delete();
$r = $this->getJson('/api/deals?only_deleted=true');
expect($r->json('total'))->toBe(2);
$ids = collect($r->json('deals'))->pluck('id')->all();
expect($ids)->not->toContain($alive->id);
expect($ids)->toContain($deleted1->id);
expect($ids)->toContain($deleted2->id);
});
test('GET /api/deals (без only_deleted) НЕ возвращает soft-deleted (default)', function () {
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted->delete();
$r = $this->getJson('/api/deals');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.id'))->toBe($alive->id);
});
test('GET /api/deals?only_deleted=true изолирует чужие удалённые сделки', 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();
$foreign->delete();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
$own->delete();
$r = $this->getJson('/api/deals?only_deleted=true');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.id'))->toBe($own->id);
});
test('GET /api/deals возвращает manager_name/initials = null если manager_id null', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.manager_id'))->toBeNull();
expect($r->json('deals.0.manager_name'))->toBeNull();
expect($r->json('deals.0.manager_initials'))->toBeNull();
});
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
test('GET /api/deals с cursor возвращает следующую страницу через keyset', function () {
// 5 сделок с received_at через 1 минуту (фиксируем порядок).
$base = now()->subHours(5);
$ids = [];
for ($i = 0; $i < 5; $i++) {
$ids[] = Deal::factory()
->for($this->tenant)
->for($this->project)
->create([
'status' => 'new',
'received_at' => $base->copy()->addMinutes($i),
])->id;
}
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
$r1 = $this->getJson('/api/deals?limit=2');
$r1->assertStatus(200);
expect($r1->json('deals'))->toHaveLength(2);
expect($r1->json('deals.0.id'))->toBe($ids[4]);
expect($r1->json('deals.1.id'))->toBe($ids[3]);
// Cursor для следующей страницы — base64 от {r:received_at,i:id} последнего элемента.
$cursor = base64_encode((string) json_encode([
'r' => $r1->json('deals.1.received_at'),
'i' => $r1->json('deals.1.id'),
]));
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
$r2->assertStatus(200);
expect($r2->json('deals'))->toHaveLength(2);
expect($r2->json('deals.0.id'))->toBe($ids[2]);
expect($r2->json('deals.1.id'))->toBe($ids[1]);
});
test('GET /api/deals с невалидным cursor возвращает 422', function () {
$r = $this->getJson('/api/deals?cursor=not-base64-json');
$r->assertStatus(422);
expect($r->json('message'))->toBeString();
});
test('GET /api/deals возвращает next_cursor когда есть ещё страницы', function () {
$base = now()->subHours(3);
for ($i = 0; $i < 3; $i++) {
Deal::factory()->for($this->tenant)->for($this->project)->create([
'status' => 'new',
'received_at' => $base->copy()->addMinutes($i),
]);
}
$r = $this->getJson('/api/deals?limit=2');
$r->assertStatus(200);
expect($r->json('next_cursor'))->toBeString();
expect($r->json('next_cursor'))->not->toBeEmpty();
// Последняя страница: next_cursor = null.
$cursor = $r->json('next_cursor');
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
$r2->assertStatus(200);
expect($r2->json('next_cursor'))->toBeNull();
});
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
$r = $this->getJson('/api/deals?count_only=1');
$r->assertStatus(200);
expect($r->json('total'))->toBe(2);
expect($r->json('deals'))->toBeNull();
});
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
});
test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreignProject = Project::factory()->for($this->otherTenant)->create();
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
});
test('GET /api/deals фильтрует по received_from/received_to', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);
$r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');
expect($r->json('total'))->toBe(1);
});
test('GET /api/deals received_to включает весь день (конец дня)', function () {
// 20:30 по Гринвичу = 23:30 в Москве, то есть поздний вечер ШЕСТНАДЦАТОГО.
// Раньше здесь стояло «23:30» гринвичских — по московскому календарю это уже
// 02:30 семнадцатого, и проверка закрепляла старую ошибку: сутки фильтра
// считались гринвичские, а в списке у лида стоит московское время.
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 20:30:00']);
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
});
test('GET /api/deals возвращает comment/city/project_signal_type', function () {
$this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'comment' => 'перезвонить',
'city' => 'Казань',
]);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.comment'))->toBe('перезвонить');
expect($r->json('deals.0.city'))->toBe('Казань');
expect($r->json('deals.0.project_signal_type'))->toBe('call');
});
test('GET /api/deals возвращает 422 на невалидную received_from', function () {
$this->getJson('/api/deals?received_from=не-дата')->assertStatus(422);
});
test('GET /api/deals возвращает 422 на невалидную received_to', function () {
$this->getJson('/api/deals?received_to=garbage')->assertStatus(422);
});
/* ----------------------------------------------------------------------
* U4 (UX-аудит 25.06): цена лида должна быть видна и в листинге, не только
* в детальном show — иначе панель и канбан показывают «—» при реальном списании.
* -------------------------------------------------------------------- */
test('GET /api/deals отдаёт cost_kopecks из lead_charges для rub-списания', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
LeadCharge::create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 7000,
'charged_at' => now(),
'created_at' => now(),
]);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.cost_kopecks'))->toBe(7000);
});
test('GET /api/deals cost_kopecks = null если списания нет', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create();
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.cost_kopecks'))->toBeNull();
});
/* ----------------------------------------------------------------------
* Автоподбор (2026-07-08): конкурент + источник в списке сделок.
* Связь deal.project_id → autopodbor_sources.created_project_id → competitor.
* -------------------------------------------------------------------- */
/**
* Создаёт конкурента автоподбора + его источники (box=field), привязанные к проекту.
* $sources — массив пар [signal_type, identifier].
*/
function makeAutopodborField(int $tenantId, int $projectId, string $name, array $sources): AutopodborCompetitor
{
$run = AutopodborRun::create(['tenant_id' => $tenantId, 'kind' => 'study']);
$competitor = AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'name' => $name,
'description' => 'микрокредитная компания',
'relevance_pct' => 58,
'dedup_key' => 'dk-'.Str::random(10),
'box' => 'field',
]);
foreach ($sources as [$signalType, $ident]) {
AutopodborSource::create([
'tenant_id' => $tenantId,
'competitor_id' => $competitor->id,
'study_run_id' => $run->id,
'signal_type' => $signalType,
'identifier' => $ident,
'dedup_key' => 'sk-'.Str::random(10),
'created_project_id' => $projectId,
'box' => 'field',
]);
}
return $competitor;
}
test('GET /api/deals отдаёт competitor_* и source_* для автоподбор-сделки', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$competitor = makeAutopodborField($this->tenant->id, $this->project->id, 'AutoMoney', [
['call', '79135396546'],
]);
Deal::factory()->for($this->tenant)->create([
'project_id' => $this->project->id,
'phone' => '79000000001',
'received_at' => now(),
]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBe($competitor->id);
expect($res['competitor_name'])->toBe('AutoMoney');
expect($res['source_identifier'])->toBe('79135396546');
expect($res['source_signal_type'])->toBe('call');
expect($res['source_project_id'])->toBe($this->project->id);
expect($res['source_is_active'])->toBeTrue();
expect($res['source_extra_count'])->toBe(0);
});
test('GET /api/deals: merge источников на проект → один конкурент, source_extra_count>0', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
makeAutopodborField($this->tenant->id, $this->project->id, 'Сфера займа24', [
['call', '79029826282'],
['site', 'sfera.ru'],
]);
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBe('Сфера займа24');
expect($res['source_extra_count'])->toBe(1);
});
test('GET /api/deals: сделка без автоподбор-источника → competitor/source = null', function () {
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project2->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBeNull();
expect($res['competitor_name'])->toBeNull();
expect($res['source_identifier'])->toBeNull();
expect($res['source_project_id'])->toBeNull();
expect($res['source_is_active'])->toBeNull();
expect($res['source_extra_count'])->toBe(0);
});
test('GET /api/deals: источник чужого tenant не подтягивается к сделке', function () {
// Источник чужого tenant с created_project_id нашего проекта — where(tenant_id)+RLS отсекают.
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
makeAutopodborField($this->otherTenant->id, $this->project->id, 'Чужой', [['call', '70000000000']]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
test('GET /api/deals срезает канальный префикс B<N>_ из project_name (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru']);
Deal::factory()->for($this->tenant)->for($bgProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.project_name'))->toBe('okna.ru');
});