aaeab9c62b
POST /api/sales/ad-audience/channels — начальник меняет channels у отмеченных фирм разом (гейт denyIfNotHead, менеджеру 403). firms() теперь отдаёт channels в списке. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
12 KiB
PHP
282 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\SalesAdAudienceFirm;
|
||
use App\Models\SalesAdAudiencePhone;
|
||
use App\Models\SalesAdAudienceState;
|
||
use App\Models\SalesProspect;
|
||
use App\Models\SalesUser;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
/**
|
||
* Экран «Реклама на кандидатов» в кабинете начальника (Task 8, бэк).
|
||
*
|
||
* GET /api/sales/ad-audience — состояние: три числа, настройки, метка синхронизации
|
||
* PATCH /api/sales/ad-audience — рубильник и срок хранения номера
|
||
*
|
||
* Оба метода — ТОЛЬКО начальник (role=head), как у SalesManagersController. Менеджер → 403.
|
||
* Аутентификация: $this->actingAs($user, 'sales').
|
||
*
|
||
* SharesSupplierPdo обязателен: SalesAdAudiencePhone/State прибиты к 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/ad-audience')
|
||
->assertForbidden();
|
||
});
|
||
|
||
test('менеджер не может тронуть настройки рекламы', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => false, 'days' => 30]);
|
||
|
||
$this->actingAs(ads_manager(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => true])
|
||
->assertForbidden();
|
||
|
||
expect(SalesAdAudienceState::current()->fresh()->enabled)->toBeFalse();
|
||
});
|
||
|
||
test('без входа экран закрыт', function () {
|
||
$this->getJson('/api/sales/ad-audience')->assertUnauthorized();
|
||
});
|
||
|
||
// ── состояние ─────────────────────────────────────────────────────────────────
|
||
|
||
test('начальник видит три числа по номерам', function () {
|
||
SalesAdAudienceState::current()->update([
|
||
'enabled' => true, 'days' => 30, 'yandex_segment_id' => 777, 'last_error' => null,
|
||
]);
|
||
|
||
// В рекламе сейчас: живые (не удалённые, срок не вышел) — 3 штуки.
|
||
ads_phone('79990000002', ['synced_at' => now(), 'expires_at' => now()->addDays(30)]);
|
||
ads_phone('79990000003', ['synced_at' => now(), 'expires_at' => now()->addDays(3)]); // выходит на неделе
|
||
ads_phone('79990000004', ['expires_at' => now()->addDays(20)]); // ждёт отправки
|
||
// Просроченный и удалённый в счёт не идут.
|
||
ads_phone('79990000005', ['synced_at' => now(), 'expires_at' => now()->subDay()]);
|
||
ads_phone('79990000006', ['synced_at' => now(), 'removed_at' => now()]);
|
||
|
||
$response = $this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->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.segment_id', 777);
|
||
});
|
||
|
||
test('пустая аудитория отдаёт нули, а не ошибку', function () {
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->assertOk()
|
||
->assertJsonPath('data.in_ads', 0)
|
||
->assertJsonPath('data.expiring_week', 0)
|
||
->assertJsonPath('data.waiting_sync', 0);
|
||
});
|
||
|
||
test('ошибка последней синхронизации видна начальнику', function () {
|
||
SalesAdAudienceState::current()->update(['last_error' => 'не задан YANDEX_AUDIENCE_TOKEN']);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->getJson('/api/sales/ad-audience')
|
||
->assertOk()
|
||
->assertJsonPath('data.last_error', 'не задан YANDEX_AUDIENCE_TOKEN');
|
||
});
|
||
|
||
// ── настройки ─────────────────────────────────────────────────────────────────
|
||
|
||
test('начальник включает рекламу и меняет срок', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => false, 'days' => 30]);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => true, 'days' => 45])
|
||
->assertOk()
|
||
->assertJsonPath('data.enabled', true)
|
||
->assertJsonPath('data.days', 45);
|
||
|
||
$state = SalesAdAudienceState::current()->fresh();
|
||
expect($state->enabled)->toBeTrue()->and($state->days)->toBe(45);
|
||
});
|
||
|
||
test('можно менять только рубильник, срок остаётся прежним', function () {
|
||
SalesAdAudienceState::current()->update(['enabled' => true, 'days' => 15]);
|
||
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['enabled' => false])
|
||
->assertOk()
|
||
->assertJsonPath('data.enabled', false)
|
||
->assertJsonPath('data.days', 15);
|
||
});
|
||
|
||
test('бессмысленный срок отвергается', function () {
|
||
SalesAdAudienceState::current()->update(['days' => 30]);
|
||
|
||
foreach ([0, -5, 400, 'много'] as $bad) {
|
||
$this->actingAs(ads_head(), 'sales')
|
||
->patchJson('/api/sales/ad-audience', ['days' => $bad])
|
||
->assertStatus(422);
|
||
}
|
||
|
||
expect(SalesAdAudienceState::current()->fresh()->days)->toBe(30);
|
||
});
|
||
|
||
// ── Task 7: список прогрева и назначение менеджера ──────────────────────────
|
||
|
||
it('начальник видит список фирм на прогреве', function () {
|
||
$firm = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Тестовая стоматология',
|
||
'city' => 'Москва',
|
||
'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/ad-audience/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/ad-audience/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/ad-audience/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/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $this->manager->id])
|
||
->assertStatus(422);
|
||
});
|
||
|
||
// ── Task 5: массовая смена площадки прогрева ────────────────────────────────
|
||
|
||
it('начальник меняет площадку сразу у нескольких фирм', function () {
|
||
$first = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Первая', 'warmup_started_at' => now(), 'channels' => 'yandex',
|
||
]);
|
||
$second = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Вторая', 'warmup_started_at' => now(), 'channels' => 'yandex',
|
||
]);
|
||
|
||
$this->actingAs($this->head, 'sales')
|
||
->postJson('/api/sales/ad-audience/channels', [
|
||
'firm_ids' => [$first->id, $second->id],
|
||
'channels' => 'both',
|
||
])
|
||
->assertOk()
|
||
->assertJson(['updated' => 2]);
|
||
|
||
expect($first->fresh()->channels)->toBe('both')
|
||
->and($second->fresh()->channels)->toBe('both');
|
||
});
|
||
|
||
it('менеджеру менять площадку нельзя', function () {
|
||
$firm = SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Чужая', 'warmup_started_at' => now(),
|
||
]);
|
||
|
||
$this->actingAs($this->manager, 'sales')
|
||
->postJson('/api/sales/ad-audience/channels', [
|
||
'firm_ids' => [$firm->id], 'channels' => 'vk',
|
||
])
|
||
->assertStatus(403);
|
||
|
||
expect($firm->fresh()->channels)->toBe('yandex');
|
||
});
|
||
|
||
it('в списке фирм видно, где греем', function () {
|
||
SalesAdAudienceFirm::create([
|
||
'firm_name' => 'Показательная', 'warmup_started_at' => now(), 'channels' => 'vk',
|
||
]);
|
||
|
||
$this->actingAs($this->head, 'sales')
|
||
->getJson('/api/sales/ad-audience/firms')
|
||
->assertOk()
|
||
->assertJsonFragment(['channels' => 'vk']);
|
||
});
|