Files
portal/app/tests/Feature/Sales/WarmingEpisodeRecorderTest.php
T
Дмитрий 80c9ca7e67 feat(витрина B1,B2,B4,B5): летопись эпизодов прогрева + значки в воронке; уборка мёртвых скоупов
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
  (open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
  и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
  тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
  осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).

Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:54:09 +03:00

76 lines
2.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudienceWarmingEpisode;
use App\Services\Sales\WarmingEpisodeRecorder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function episodeFirm(): SalesAdAudienceFirm
{
return SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
}
it('open заводит открытый эпизод канала', function () {
$firm = episodeFirm();
(new WarmingEpisodeRecorder)->open($firm->id, 'yandex');
$ep = SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'yandex')->sole();
expect($ep->ended_at)->toBeNull();
});
it('повторный open уже открытого эпизода не плодит второй', function () {
$firm = episodeFirm();
$rec = new WarmingEpisodeRecorder;
$rec->open($firm->id, 'yandex');
$rec->open($firm->id, 'yandex');
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'yandex')->count())->toBe(1);
});
it('close проставляет ended_at открытому эпизоду', function () {
$firm = episodeFirm();
$rec = new WarmingEpisodeRecorder;
$rec->open($firm->id, 'yandex');
$rec->close($firm->id, 'yandex');
$ep = SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'yandex')->sole();
expect($ep->ended_at)->not->toBeNull();
});
it('повторный open после close заводит второй эпизод (грели дважды)', function () {
$firm = episodeFirm();
$rec = new WarmingEpisodeRecorder;
$rec->open($firm->id, 'yandex');
$rec->close($firm->id, 'yandex');
$rec->open($firm->id, 'yandex');
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'yandex')->count())->toBe(2);
});
it('record пишет мгновенный эпизод СМС (started_at = ended_at)', function () {
$firm = episodeFirm();
(new WarmingEpisodeRecorder)->record($firm->id, 'sms');
$ep = SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->sole();
expect($ep->ended_at)->not->toBeNull()
->and($ep->started_at->timestamp)->toBe($ep->ended_at->timestamp);
});
it('close без открытого эпизода — тихий no-op', function () {
$firm = episodeFirm();
(new WarmingEpisodeRecorder)->close($firm->id, 'yandex');
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->count())->toBe(0);
});