bb20a8859d
feat(sales): приём номеров в рекламную аудиторию Сервис-канал POST /api/sales/integration/ad-audience (тот же X-Sales-Token, что и «Отдать менеджеру»). Новый номер добавляем, известный — продлеваем срок из настройки и просим ночной джоб дослать (synced_at=NULL, removed_at=NULL). Формат Яндекса строгий: 11 цифр с 7, без плюса. Task 3 плана 2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika. Моделям дописаны @property-докблоки (конвенция проекта, как у SalesProspect) — без них Larastan не видит полей. Тесты: 6 в tests/Feature/Sales/AdAudienceIntakeTest.php, вся Sales-пачка 264 зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
107 lines
4.9 KiB
PHP
107 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\SalesAdAudiencePhone;
|
|
use App\Models\SalesAdAudienceState;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
/**
|
|
* Приём номеров в рекламную аудиторию сервис-каналом «Поиск → Портал».
|
|
*
|
|
* Токен канала — config('sales.integration_token') (middleware SalesIntegrationToken).
|
|
* В тестовом окружении env пуст, поэтому задаём его здесь, как в SalesIntegrationApiTest.
|
|
*
|
|
* SharesSupplierPdo обязателен: обе модели прибиты к соединению pgsql_supplier
|
|
* (отдельный PDO), и без шаринга DatabaseTransactions откатывал бы только
|
|
* соединение по умолчанию — записи текли бы из теста в тест.
|
|
*/
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
beforeEach(function () {
|
|
config(['sales.integration_token' => 'TESTTOKEN']);
|
|
});
|
|
|
|
test('без токена канал закрыт', function () {
|
|
$this->postJson('/api/sales/integration/ad-audience', ['phones' => []])
|
|
->assertUnauthorized();
|
|
});
|
|
|
|
test('принимает номера и ставит срок по настройке', function () {
|
|
SalesAdAudienceState::current()->update(['days' => 10]);
|
|
|
|
$this->postJson('/api/sales/integration/ad-audience', ['phones' => [[
|
|
'phone' => '79990000009', 'firm_inn' => '5505219940',
|
|
'firm_name' => 'Демидовы', 'city' => 'Омск', 'rubric' => 'chastnye-stomatologii',
|
|
]]], ['X-Sales-Token' => 'TESTTOKEN'])
|
|
->assertOk()->assertJson(['added' => 1, 'renewed' => 0]);
|
|
|
|
$row = SalesAdAudiencePhone::query()->where('phone', '79990000009')->firstOrFail();
|
|
|
|
expect($row->firm_name)->toBe('Демидовы')
|
|
->and($row->city)->toBe('Омск')
|
|
->and($row->firm_inn)->toBe('5505219940')
|
|
->and($row->removed_at)->toBeNull()
|
|
->and($row->synced_at)->toBeNull();
|
|
|
|
// Срок = момент приёма + 10 дней (минута допуска на время прогона теста).
|
|
expect($row->expires_at->timestamp)
|
|
->toBeGreaterThan(now()->addDays(10)->subMinute()->timestamp)
|
|
->toBeLessThan(now()->addDays(10)->addMinute()->timestamp);
|
|
});
|
|
|
|
test('повторная отправка продлевает срок, а не плодит дубль', function () {
|
|
$this->postJson('/api/sales/integration/ad-audience',
|
|
['phones' => [['phone' => '79990000009', 'firm_name' => 'Демидовы']]],
|
|
['X-Sales-Token' => 'TESTTOKEN'])->assertOk();
|
|
|
|
$first = SalesAdAudiencePhone::query()->where('phone', '79990000009')->firstOrFail()->expires_at;
|
|
|
|
$this->travel(2)->days();
|
|
|
|
$this->postJson('/api/sales/integration/ad-audience',
|
|
['phones' => [['phone' => '79990000009', 'firm_name' => 'Демидовы']]],
|
|
['X-Sales-Token' => 'TESTTOKEN'])
|
|
->assertOk()->assertJson(['added' => 0, 'renewed' => 1]);
|
|
|
|
expect(SalesAdAudiencePhone::query()->where('phone', '79990000009')->count())->toBe(1);
|
|
|
|
$again = SalesAdAudiencePhone::query()->where('phone', '79990000009')->firstOrFail();
|
|
expect($again->expires_at->timestamp)->toBeGreaterThan($first->timestamp);
|
|
});
|
|
|
|
test('вернувшийся номер снимает пометку удаления и просит пересинк', function () {
|
|
$this->postJson('/api/sales/integration/ad-audience',
|
|
['phones' => [['phone' => '79990000009']]],
|
|
['X-Sales-Token' => 'TESTTOKEN'])->assertOk();
|
|
|
|
SalesAdAudiencePhone::query()->where('phone', '79990000009')
|
|
->update(['removed_at' => now(), 'synced_at' => now()]);
|
|
|
|
$this->postJson('/api/sales/integration/ad-audience',
|
|
['phones' => [['phone' => '79990000009']]],
|
|
['X-Sales-Token' => 'TESTTOKEN'])
|
|
->assertOk()->assertJson(['renewed' => 1]);
|
|
|
|
$row = SalesAdAudiencePhone::query()->where('phone', '79990000009')->firstOrFail();
|
|
expect($row->removed_at)->toBeNull()->and($row->synced_at)->toBeNull();
|
|
});
|
|
|
|
test('отвергает номер не в формате Яндекса', function () {
|
|
foreach (['+79990000009', '89990000009', '7906197416', '799900000090'] as $bad) {
|
|
$this->postJson('/api/sales/integration/ad-audience',
|
|
['phones' => [['phone' => $bad]]],
|
|
['X-Sales-Token' => 'TESTTOKEN'])->assertStatus(422);
|
|
}
|
|
|
|
expect(SalesAdAudiencePhone::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('пустой список номеров отвергается', function () {
|
|
$this->postJson('/api/sales/integration/ad-audience', [], ['X-Sales-Token' => 'TESTTOKEN'])
|
|
->assertStatus(422);
|
|
$this->postJson('/api/sales/integration/ad-audience', ['phones' => []], ['X-Sales-Token' => 'TESTTOKEN'])
|
|
->assertStatus(422);
|
|
});
|