Files
portal/app/tests/Feature/Bot/GuardCardContractTest.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

127 lines
6.5 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);
/**
* СВЯЗКА между генератором карточки (ClientFacts) и сторожом (AnswerGuard).
*
* AnswerGuardTest.php ловит перепутанные цифры на карточке, написанной ВРУЧНУЮ в самом
* тесте (trapCard()) — она устроена так, как сторож ЕЁ понимает, но не проверяет, что
* сторож понимает НАСТОЯЩУЮ карточку, которую строит ClientFacts::card() на реальных
* данных. Если кто-то поменяет формулировку в ClientFacts («Баланс:» → «Ваш баланс:»),
* сторож молча перестанет находить факты — и пропустит враньё про цифры клиента.
* Ручной тест этого не заметит, а этот — обязан.
*
* 🔴 Если этот тест УПАЛ после правки текста в ClientFacts — это значит сторож ОСЛЕП,
* а не что тест «слишком строгий». Чинить нужно разбор в AnswerGuard::parseCardFacts()
* (синхронно с новой формулировкой), а НЕ подгонять этот тест под новый текст.
*/
use App\Models\Deal;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Bot\AnswerGuard;
use App\Services\Bot\ClientFacts;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Часы держим на полудне по Москве — как в ClientFactsTest. Карточка считает
// «сегодня» по московским суткам, а заявки здесь кладутся от `now()`; ночью,
// с 00:00 до 03:00 МСК, эти два дня расходятся и проверка краснела бы.
beforeEach(function (): void {
$polden = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addHours(12);
Carbon::setTestNow($polden);
CarbonImmutable::setTestNow($polden);
});
afterEach(function (): void {
Carbon::setTestNow();
CarbonImmutable::setTestNow();
});
/**
* Те же две ступени, что в ClientFactsTest::tiers() — имя своё (guardContract*), чтобы
* не столкнуться с глобальной функцией `tiers()` из ClientFactsTest.php при общем прогоне.
*/
function guardContractPricingTiers(): void
{
PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => 300, 'price_per_lead_kopecks' => 5000]);
PricingTier::factory()->create(['tier_no' => 2, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 4000]);
}
/**
* Строит НАСТОЯЩУЮ карточку через ClientFacts::card() на нарочно подобранных данных:
* баланс 2 000 ₽ по 50 ₽/заявку (1-я ступень) = РОВНО 40 заявок — число нарочно совпадает
* с «получено в этом месяце» (12) не будучи им, чтобы ловушка «перепутанные местами
* цифры» была видна: 40 в карточке РЕАЛЬНО есть (баланс), но не как «получено в этом
* месяце»; 12 — другое число (месяц), не «сегодня»; 3 — третье число (сегодня).
*/
function guardContractRealCard(): string
{
guardContractPricingTiers();
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'delivered_in_month' => 12]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10,
]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 5,
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
// Сегодня — 3 заявки (received_at внутри текущего месяца — партиции deals помесячные).
Deal::factory()->count(3)->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'received_at' => now()->startOfDay()->addHours(9),
]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->not->toBeNull();
// Проверяем саму ловушку ДО сторожа: если это условие не выполняется — подстроенные
// данные сломались, а не сторож (иначе тесты ниже могли бы пройти по случайной причине).
expect($card)->toContain('40 заявок')
->and($card)->toContain('получено в этом месяце 12')
->and($card)->toContain('Заявки: сегодня 3');
return (string) $card;
}
it('а) враньё режется: «получено 40» — число из ДРУГОГО факта настоящей карточки (баланс)', function () {
$card = guardContractRealCard();
$guard = app(AnswerGuard::class);
expect($guard->clean('В этом месяце получено 40 заявок.', $card))->toBe('');
});
it('б) враньё режется: «сегодня 12» — число из ДРУГОГО факта настоящей карточки (месяц)', function () {
$card = guardContractRealCard();
$guard = app(AnswerGuard::class);
expect($guard->clean('Сегодня пришло 12 заявок.', $card))->toBe('');
});
it('в) честное остаётся: «получено 12» — ровно то число, что в настоящей карточке', function () {
$card = guardContractRealCard();
$guard = app(AnswerGuard::class);
expect($guard->clean('В этом месяце получено 12 заявок.', $card))
->toBe('В этом месяце получено 12 заявок.');
});
it('г) честное остаётся: «сегодня 3» — ровно то число, что в настоящей карточке', function () {
$card = guardContractRealCard();
$guard = app(AnswerGuard::class);
expect($guard->clean('Сегодня пришло 3 заявки.', $card))
->toBe('Сегодня пришло 3 заявки.');
});