Files
portal/app/tests/Feature/Sales/AdAudienceScreenTest.php
T

821 lines
37 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Экран «Реклама на кандидатов» в кабинете начальника — по площадкам (Task 8, бэк;
* разъезд на площадки — план трёх площадок Task 4+5).
*
* GET /api/sales/warming/{platform} — состояние: три числа, настройки, метка синхронизации
* PATCH /api/sales/warming/{platform} — рубильник площадки и срок хранения номера
*
* Оба метода — ТОЛЬКО начальник (role=head), как у SalesManagersController. Менеджер → 403.
* Аутентификация: $this->actingAs($user, 'sales').
*
* Три числа (in_ads/expiring_week/waiting_sync) считаются ТОЛЬКО по номерам фирм,
* подписанных на конкретную площадку (ch_yandex/ch_vk/ch_mts на sales_ad_audience_firms) —
* поэтому в тестах на числа номера всегда привязаны к firm_id фирмы с ch_yandex=true.
*
* SharesSupplierPdo обязателен: SalesAdAudiencePhone/State/Platform прибиты к pgsql_supplier
* (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только соединение
* по умолчанию — записи текли бы из теста в тест.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// Task 7: список фирм на прогреве и назначение менеджера — тестам нужны оба
// пользователя сразу, заводим тем же приёмом, что и остальные тесты файла.
beforeEach(function () {
$this->head = ads_head();
$this->manager = ads_manager();
});
// ── helpers (уникальный префикс ads_) ────────────────────────────────────────
function ads_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'adshead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function ads_manager(): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'adsmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
}
/** Номер в аудитории: живой, просроченный или не отправленный — по аргументам. */
function ads_phone(string $phone, array $attrs = []): SalesAdAudiencePhone
{
return SalesAdAudiencePhone::create(array_merge([
'phone' => $phone,
'added_at' => now(),
'expires_at' => now()->addDays(30),
], $attrs));
}
// ── доступ ────────────────────────────────────────────────────────────────────
test('менеджер не видит экран рекламы', function () {
$this->actingAs(ads_manager(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertForbidden();
});
test('менеджер не может тронуть настройки рекламы', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false]);
$this->actingAs(ads_manager(), 'sales')
->patchJson('/api/sales/warming/yandex', ['enabled' => true])
->assertForbidden();
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeFalse();
});
test('без входа экран закрыт', function () {
$this->getJson('/api/sales/warming/yandex')->assertUnauthorized();
});
// ── состояние ─────────────────────────────────────────────────────────────────
test('начальник видит три числа по номерам', function () {
SalesAdAudiencePlatform::forCode('yandex')->update([
'enabled' => true, 'external_id' => 777, 'last_error' => null, 'days' => 30,
]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'На Яндекс', 'ch_yandex' => true]);
// Task C1: платформо-числа считаются по строке канала firm_channels(yandex, warming),
// а не по флагу ch_yandex.
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 3 штуки.
ads_phone('79990000002', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
ads_phone('79990000003', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->addDays(3)]); // выходит на неделе
ads_phone('79990000004', ['firm_id' => $firm->id, 'expires_at' => now()->addDays(20)]); // ждёт отправки
// Просроченный и удалённый в счёт не идут.
ads_phone('79990000005', ['firm_id' => $firm->id, 'synced_at' => now(), 'expires_at' => now()->subDay()]);
ads_phone('79990000006', ['firm_id' => $firm->id, 'synced_at' => now(), 'removed_at' => now()]);
$response = $this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertOk();
$response->assertJsonPath('data.in_ads', 3)
->assertJsonPath('data.expiring_week', 1)
->assertJsonPath('data.waiting_sync', 1)
->assertJsonPath('data.enabled', true)
->assertJsonPath('data.days', 30)
->assertJsonPath('data.external_id', 777);
});
test('пустая аудитория отдаёт нули, а не ошибку', function () {
$this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertOk()
->assertJsonPath('data.in_ads', 0)
->assertJsonPath('data.expiring_week', 0)
->assertJsonPath('data.waiting_sync', 0);
});
test('ошибка последней синхронизации видна начальнику', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['last_error' => 'не задан YANDEX_AUDIENCE_TOKEN']);
$this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertOk()
->assertJsonPath('data.last_error', 'не задан YANDEX_AUDIENCE_TOKEN');
});
// ── настройки ─────────────────────────────────────────────────────────────────
test('начальник включает рекламу и меняет срок', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => false, 'days' => 30]);
$this->actingAs(ads_head(), 'sales')
->patchJson('/api/sales/warming/yandex', ['enabled' => true, 'days' => 45])
->assertOk()
->assertJsonPath('data.enabled', true)
->assertJsonPath('data.days', 45);
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->enabled)->toBeTrue();
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->days)->toBe(45);
});
test('можно менять только рубильник, срок остаётся прежним', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'days' => 15]);
$this->actingAs(ads_head(), 'sales')
->patchJson('/api/sales/warming/yandex', ['enabled' => false])
->assertOk()
->assertJsonPath('data.enabled', false)
->assertJsonPath('data.days', 15);
});
test('бессмысленный срок отвергается', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['days' => 30]);
foreach ([0, -5, 400, 'много'] as $bad) {
$this->actingAs(ads_head(), 'sales')
->patchJson('/api/sales/warming/yandex', ['days' => $bad])
->assertStatus(422);
}
expect(SalesAdAudiencePlatform::forCode('yandex')->fresh()->days)->toBe(30);
});
// ── Task 7: список прогрева и назначение менеджера ──────────────────────────
it('начальник видит список фирм на прогреве', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Тестовая стоматология',
'city' => 'Москва',
'ch_yandex' => true,
'warmup_started_at' => now()->subDays(2),
]);
// Task 8: state считается по строке канала firm_channels(yandex, warming).
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001',
'firm_id' => $firm->id,
'expires_at' => now()->addDay(),
'state' => 'active',
]);
$this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->assertJsonPath('firms.0.firm_name', 'Тестовая стоматология')
->assertJsonPath('firms.0.state', 'active')
->assertJsonPath('firms.0.days_in_ads', 2);
});
it('менеджер список прогрева не видит', function () {
$this->actingAs($this->manager, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertStatus(403);
});
it('начальник назначает менеджера и получает карточку в воронке', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Тестовая стоматология',
'firm_inn' => '7700000001',
'city' => 'Москва',
'payload' => ['site' => 'example.test'],
'contacts' => [['name' => 'Директор', 'phones' => ['79990000001']]],
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", [
'sales_user_id' => $this->manager->id,
])
->assertOk();
$firm->refresh();
$prospect = SalesProspect::findOrFail($firm->prospect_id);
expect($prospect->sales_user_id)->toBe($this->manager->id)
->and($prospect->stage)->toBe('new')
->and($prospect->assigned_by)->toBe($this->head->id)
->and($prospect->contacts)->toHaveCount(1)
->and($firm->assigned_at)->not->toBeNull();
});
it('повторное назначение той же фирмы отвергается', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология', 'prospect_id' => 1]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", ['sales_user_id' => $this->manager->id])
->assertStatus(422);
});
2026-07-25 13:05:57 +03:00
// ── Уже в работе: карточка воронки по ИНН существует до прогрева ─────────────
// Фирма попадает в прогрев отдельно от воронки (через «Поиск клиентов»), поэтому
// строка прогрева не привязана к карточке. Экран должен узнать карточку по ИНН:
// показать, КТО ведёт клиента и на каком этапе канбана — вместо «Назначить».
it('фирма без привязки, но с карточкой воронки по ИНН — показывает менеджера и этап', function () {
SalesProspect::create([
'sales_user_id' => $this->manager->id,
'assigned_by' => $this->head->id,
'stage' => 'no_answer',
'source' => 'search',
'firm_name' => 'Новодент',
'inn' => '6673240656',
]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Новодент',
'firm_inn' => '6673240656',
'warmup_started_at' => now(),
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->assertJsonPath('firms.0.firm_name', 'Новодент')
->assertJsonPath('firms.0.manager_name', $this->manager->name)
->assertJsonPath('firms.0.stage', 'no_answer');
});
it('назначение фирмы с существующей карточкой по ИНН не плодит дубль — привязывается к ней', function () {
$existing = SalesProspect::create([
'sales_user_id' => $this->manager->id,
'assigned_by' => $this->head->id,
'stage' => 'no_answer',
'source' => 'search',
'firm_name' => 'Новодент',
'inn' => '6673240656',
]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Новодент',
'firm_inn' => '6673240656',
'warmup_started_at' => now(),
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/yandex/firms/{$firm->id}/assign", [
'sales_user_id' => $this->manager->id,
])
->assertOk();
// Дубль НЕ создан: карточка с этим ИНН по-прежнему одна.
expect(SalesProspect::where('inn', '6673240656')->count())->toBe(1);
// Строка прогрева привязана к СУЩЕСТВУЮЩЕЙ карточке.
expect($firm->fresh()->prospect_id)->toBe($existing->id);
});
// ── Task 3 (план трёх площадок): файл МТС ───────────────────────────────────
it('отдаёт файл для МТС только по фирмам, помеченным МТС', function () {
$mts = SalesAdAudienceFirm::create([
'firm_name' => 'Для МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
]);
// Task 7: источник состава — строка канала firm_channels(mts, warming), а не флаг ch_mts.
$mts->firmChannels()->create([
'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $mts->warmup_started_at ?? now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001', 'firm_id' => $mts->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$chuzhaya = SalesAdAudienceFirm::create([
'firm_name' => 'Только Яндекс', 'warmup_started_at' => now(), 'ch_yandex' => true,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000002', 'firm_id' => $chuzhaya->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$response = $this->actingAs($this->head, 'sales')->get('/api/sales/warming/mts/file');
$response->assertOk();
$response->assertHeader('content-type', 'text/plain; charset=UTF-8');
$telo = $response->streamedContent();
expect($telo)->toContain('79990000001')
->and($telo)->not->toContain('79990000002');
});
it('фирма, погасшая по срокам МТС, в файл не попадает', function () {
SalesAdAudiencePlatform::forCode('mts')->update(['warmup_days' => 3]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Для МТС', 'warmup_started_at' => now()->subDays(10), 'ch_mts' => true,
]);
// Task 7: строка канала mts warming, заезд 10 дней назад — funnel даёт stopped.
$firm->firmChannels()->create([
'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(10), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000003', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$telo = $this->actingAs($this->head, 'sales')
->get('/api/sales/warming/mts/file')
->streamedContent();
expect($telo)->not->toContain('79990000003');
});
it('фирма со строкой mts warming+active — её номер в файле', function () {
SalesAdAudiencePlatform::forCode('mts')->update(['warmup_days' => 3]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Активная по МТС', 'warmup_started_at' => now(),
]);
$firm->firmChannels()->create([
'channel' => 'mts', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000004', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$telo = $this->actingAs($this->head, 'sales')
->get('/api/sales/warming/mts/file')
->streamedContent();
expect($telo)->toContain('79990000004');
});
it('фирма со строкой mts loaded — номера в файле нет, даже с ch_mts=true', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Загруженная по МТС', 'warmup_started_at' => now(), 'ch_mts' => true,
]);
$firm->firmChannels()->create([
'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(20), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000005', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
$telo = $this->actingAs($this->head, 'sales')
->get('/api/sales/warming/mts/file')
->streamedContent();
expect($telo)->not->toContain('79990000005');
});
// ── Task 9: состояние канала ВК на экране начальника ────────────────────────
it('показывает начальнику состояние канала ВК', function () {
SalesAdAudiencePlatform::forCode('vk')->update(['status' => 'waiting_volume']);
$this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/vk')
->assertOk()
->assertJsonPath('data.status', 'waiting_volume');
});
// ── Task 8: тоггл «Греть здесь» и показ — на строки firm_channels ────────────
it('тоггл on заводит строку канала warming/funnel и инкрементит счётчик заездов', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Тоггл ВК', 'warmup_started_at' => now()->subDays(2),
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true])
->assertOk()
->assertJsonPath('ok', true);
$row = $firm->firmChannels()->where('channel', 'vk')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('funnel')
->and($row->warmed_times)->toBeGreaterThanOrEqual(1)
->and($row->warming_started_at->toDateString())->toBe($firm->warmup_started_at->toDateString());
$first = $row->warmed_times;
// Повторный тоггл on — счётчик заездов растёт, строка одна.
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true])
->assertOk();
expect($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1);
expect($firm->firmChannels()->where('channel', 'vk')->firstOrFail()->warmed_times)->toBe($first + 1);
});
it('тоггл off удаляет строку канала', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Снять с ВК', 'warmup_started_at' => now()->subDays(2),
]);
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false])
->assertOk();
expect($firm->firmChannels()->where('channel', 'vk')->count())->toBe(0);
});
it('firmRow ВК: state/warming_active считаются по строке канала', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Показ по строке', 'warmup_started_at' => now()->subDay(),
]);
// Task C1: firms(vk) отдаёт только фирмы со строкой канала vk. Загруженная строка
// (loaded) — фирма в списке, но не греется: state stopped, warming_active false.
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000077', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
// Строка loaded — фирма видна, но stopped и не активна.
$rowNone = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms.0');
expect($rowNone['state'])->toBe('stopped')
->and($rowNone['warming_active'])->toBeFalse();
// Включили — строка становится warming: active и активна.
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true])
->assertOk();
$rowOn = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms.0');
expect($rowOn['state'])->toBe('active')
->and($rowOn['warming_active'])->toBeTrue();
// Выключили — строка канала удаляется, фирма уходит из списка площадки.
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false])
->assertOk();
$firmsOff = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms');
expect($firmsOff)->toBe([]);
});
// ── Task C1: firms({platform}) — только строки канала ────────────────────────
it('firms площадки отдаёт только фирмы со строкой этого канала', function () {
$yandexFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Яндекс-фирма', 'warmup_started_at' => now()->subDay(),
]);
$yandexFirm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
$vkFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Только ВК', 'warmup_started_at' => now()->subDay(),
]);
$vkFirm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$names = collect($this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->assertOk()->json('firms'))
->pluck('firm_name');
expect($names)->toContain('Яндекс-фирма')
->and($names)->not->toContain('Только ВК');
});
test('три числа считаются по фирмам со строкой канала warming, не по ch_*', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'days' => 30]);
// Греется строкой канала, ch_yandex=false (как у новых фирм после заезда).
$warming = SalesAdAudienceFirm::create([
'firm_name' => 'Греется строкой', 'ch_yandex' => false, 'warmup_started_at' => now(),
]);
$warming->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
ads_phone('79991110001', ['firm_id' => $warming->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
// Только загружена (loaded, не греется) — в in_ads НЕ идёт.
$loaded = SalesAdAudienceFirm::create([
'firm_name' => 'Только загружена', 'warmup_started_at' => now(),
]);
$loaded->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
ads_phone('79991110002', ['firm_id' => $loaded->id, 'synced_at' => now(), 'expires_at' => now()->addDays(30)]);
$this->actingAs(ads_head(), 'sales')
->getJson('/api/sales/warming/yandex')
->assertOk()
->assertJsonPath('data.in_ads', 1);
});
it('снятие строки yandex убирает фирму из yandex, но не из vk', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Две площадки', 'warmup_started_at' => now()->subDay(),
]);
foreach (['yandex', 'vk'] as $ch) {
$firm->firmChannels()->create([
'channel' => $ch, 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
}
// Пока обе строки есть — бейджи содержат обе площадки.
$yRow = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->json('firms.0');
expect($yRow['warming_channels'])->toContain('yandex')
->and($yRow['warming_channels'])->toContain('vk');
// Убрали yandex-строку.
$firm->firmChannels()->where('channel', 'yandex')->delete();
$yNames = collect($this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->json('firms'))->pluck('firm_name');
$vRow = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')->json('firms.0');
expect($yNames)->not->toContain('Две площадки');
expect($vRow['firm_name'])->toBe('Две площадки');
expect($vRow['warming_channels'])->toContain('vk')
->and($vRow['warming_channels'])->not->toContain('yandex');
});
it('тоггл ВК не трогает строку yandex', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Независимость каналов', 'warmup_started_at' => now()->subDay(),
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true])
->assertOk();
// yandex-строка на месте и не изменилась; vk-строка появилась.
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(1)
->and($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(1)
->and($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1);
});
// ── Task C2: массовый «Греть» — funnel/flat ──────────────────────────────────
it('массовый Греть без days переводит фирму в warming/funnel', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Массовый воронка']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('ok', true);
$row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('funnel')
->and($row->flat_days)->toBeNull()
->and($row->warmed_times)->toBe(1)
->and($row->warming_started_at->diffInSeconds(now()))->toBeLessThan(5);
});
it('массовый Греть с days переводит фирму в warming/flat', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Массовый срок']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id], 'days' => 15])
->assertOk();
$row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('flat')
->and($row->flat_days)->toBe(15)
->and($row->warmed_times)->toBe(1);
});
it('массовый Греть переводит несколько фирм разом', function () {
$a = SalesAdAudienceFirm::create(['firm_name' => 'Массовая А']);
$b = SalesAdAudienceFirm::create(['firm_name' => 'Массовая Б']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$a->id, $b->id]])
->assertOk()
->assertJsonPath('warmed', 2);
expect($a->firmChannels()->where('channel', 'yandex')->firstOrFail()->status)->toBe('warming')
->and($b->firmChannels()->where('channel', 'yandex')->firstOrFail()->status)->toBe('warming');
});
it('повторный массовый Греть той же фирмы наращивает счётчик заездов', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Повтор']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(1);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(1);
expect($firm->firmChannels()->where('channel', 'yandex')->firstOrFail()->warmed_times)->toBe(2);
});
it('массовый Греть отвергает бессмысленный days', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Плохой срок']);
foreach ([0, 400, 'x'] as $bad) {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id], 'days' => $bad])
->assertStatus(422);
}
});
it('массовый Греть отвергает пустой или некорректный firm_ids', function () {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => []])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => 'не массив'])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/warm', [])
->assertStatus(422);
});
it('массовый Греть менеджеру недоступен', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Не для менеджера']);
$this->actingAs($this->manager, 'sales')
->postJson('/api/sales/warming/yandex/warm', ['firm_ids' => [$firm->id]])
->assertStatus(403);
});
it('массовый Греть на неизвестной площадке отдаёт 404', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Неизвестная площадка']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/unknown/warm', ['firm_ids' => [$firm->id]])
->assertStatus(404);
});
// ── Task C3: массовый «Убрать» — снять фирмы с площадки ──────────────────────
it('массовый Убрать удаляет строку канала и фирма уходит из списка площадки', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Убрать одну']);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('ok', true);
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(0);
$ids = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->json('firms.*.id');
expect($ids)->not->toContain($firm->id);
});
it('массовый Убрать не трогает другие каналы той же фирмы', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Убрать независимость каналов']);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertOk();
expect($firm->firmChannels()->where('channel', 'yandex')->count())->toBe(0)
->and($firm->firmChannels()->where('channel', 'vk')->count())->toBe(1);
$vkIds = $this->actingAs($this->head, 'sales')
->getJson('/api/sales/warming/vk/firms')
->assertOk()
->json('firms.*.id');
expect($vkIds)->toContain($firm->id);
});
it('массовый Убрать снимает несколько фирм разом', function () {
$a = SalesAdAudienceFirm::create(['firm_name' => 'Убрать А']);
$b = SalesAdAudienceFirm::create(['firm_name' => 'Убрать Б']);
$a->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$b->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$a->id, $b->id]])
->assertOk()
->assertJsonPath('removed', 2);
expect($a->firmChannels()->where('channel', 'yandex')->count())->toBe(0)
->and($b->firmChannels()->where('channel', 'yandex')->count())->toBe(0);
});
it('массовый Убрать отвергает пустой или некорректный firm_ids', function () {
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => []])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => 'не массив'])
->assertStatus(422);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/yandex/unassign', [])
->assertStatus(422);
});
it('массовый Убрать менеджеру недоступен', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Не для менеджера убрать']);
$this->actingAs($this->manager, 'sales')
->postJson('/api/sales/warming/yandex/unassign', ['firm_ids' => [$firm->id]])
->assertStatus(403);
});
it('массовый Убрать на неизвестной площадке отдаёт 404', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Неизвестная площадка убрать']);
$this->actingAs($this->head, 'sales')
->postJson('/api/sales/warming/unknown/unassign', ['firm_ids' => [$firm->id]])
->assertStatus(404);
});