Files
portal/app/tests/Feature/Sales/AdAudienceScreenTest.php
T
Дмитрий 32f9d87f9c feat(прогрев ф2): файл МТС берёт только строки firm_channels warming+active
Фаза 2 Этап A, Task 7. mtsFile() выбирает фирмы по строке firm_channels
(channel=mts, status=warming) вместо флага ch_mts и решает по ней через
decideForChannelRow. Фирма с mts-строкой loaded в файл не попадает, даже с
ch_mts=true. Формат файла (streamDownload, имя, сортировка) без изменений.
Тронут только mtsFile — toggle/firmRow/firms остаются на Task 8.
Бамп phpstan-baseline (+2 к actingAs/$head TestCall-ложнякам на 2 новых теста).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:47:08 +03:00

354 lines
16 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\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]);
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 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),
]);
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);
});
// ── 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');
});