'test-token']); }); function warmupPayload(array $override = []): array { return ['firms' => [array_merge([ 'firm_name' => 'Тестовая стоматология', 'firm_inn' => '7700000001', 'city' => 'Москва', 'rubric' => 'Стоматологии', 'phones' => ['79990000001', '79990000002'], 'payload' => ['site' => 'example.test'], 'contacts' => [['name' => 'Директор', 'phones' => ['79990000001']]], ], $override)]]; } it('без токена канал закрыт', function () { $this->postJson('/api/sales/integration/ad-audience', warmupPayload()) ->assertStatus(401); }); it('заводит фирму на прогрев вместе с её номерами', function () { $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token']) ->assertOk() ->assertJson(['added' => 1]); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->phones()->count())->toBe(2) ->and($firm->prospect_id)->toBeNull() ->and($firm->warmup_started_at)->not->toBeNull(); }); it('заезд грузит фирму во все 4 канала как loaded, прогрев не стартует', function () { // Смысл этапа B: заезд = загрузка, не старт. Четыре строки firm_channels // (yandex/vk/mts/sms) заводятся сразу, но ВСЕ в loaded — ни одной warming. $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token']) ->assertOk() ->assertJson(['added' => 1]); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->firmChannels()->count())->toBe(4) ->and($firm->firmChannels()->orderBy('channel')->pluck('channel')->all()) ->toBe(['mts', 'sms', 'vk', 'yandex']) ->and($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4) ->and($firm->firmChannels()->where('status', 'warming')->count())->toBe(0); }); it('загруженная (не греется) фирма не попадает в состав Яндекс-заливки', function () { // Фирма заехала (loaded), но прогрев не запускали — значит в ночной сегмент // Яндекса она входить НЕ должна: греется только warming. $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])->assertOk(); SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'external_id' => 777]); config(['services.yandex_audience.token' => 't']); Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]); // Живая (warming) фирма рядом — иначе активных номеров ноль и джоб тихо // выйдет (пустую заливку не шлём), а исключение loaded проверялось бы на // тишине. Здесь оно проверяется на настоящем составе заливки. $warm = SalesAdAudienceFirm::create(['firm_name' => 'Греется', 'ch_yandex' => true]); $warm->firmChannels()->create([ 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel', 'warming_started_at' => now(), 'warmed_times' => 1, ]); SalesAdAudiencePhone::create([ 'phone' => '79990000009', 'firm_id' => $warm->id, 'expires_at' => now()->addDays(30), 'state' => 'active', ]); SyncAdAudienceJob::dispatchSync(); Http::assertSent(function (Request $r) { $file = collect($r->data())->firstWhere('name', 'file'); $csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body(); return str_contains($csv, '79990000009') && ! str_contains($csv, '79990000001'); }); }); it('повторный заезд не понижает уже греющийся канал и не сбивает его срок', function () { // Начальник нажал «Греть» по Яндексу (loaded → warming). Повторный заезд той // же фирмы из поиска НЕ должен погасить прогрев обратно в loaded или сбить срок. $headers = ['X-Sales-Token' => 'test-token']; $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); $startedAt = now()->subDays(4); $firm->firmChannels()->where('channel', 'yandex')->update([ 'status' => 'warming', 'warming_started_at' => $startedAt, ]); $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); $row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail(); expect($row->status)->toBe('warming') ->and($row->warming_started_at->timestamp)->toBe($startedAt->timestamp); }); it('повторный заезд не сбрасывает warmup_started_at фирмы на сегодня', function () { // Заезд ≠ рестарт прогрева: срок фирмы, выставленный ранее, остаётся как был. $headers = ['X-Sales-Token' => 'test-token']; $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); SalesAdAudienceFirm::where('firm_inn', '7700000001') ->update(['warmup_started_at' => now()->subDays(3)]); $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->warmup_started_at->isToday())->toBeFalse() ->and($firm->warmup_started_at->lessThan(now()->subDays(2)))->toBeTrue(); }); it('повторная отправка не плодит дубль и не рестартует прогрев', function () { // Фаза 2 этап B: повторный заезд НЕ трогает warmup_started_at/stopped_at — // заезд грузит, а не перезапускает прогрев (раньше сбрасывал, теперь нет). $headers = ['X-Sales-Token' => 'test-token']; $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); SalesAdAudienceFirm::where('firm_inn', '7700000001') ->update(['warmup_started_at' => now()->subDays(10), 'stopped_at' => now()]); $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers) ->assertOk() ->assertJson(['added' => 0, 'renewed' => 1]); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect(SalesAdAudienceFirm::count())->toBe(1) ->and($firm->stopped_at)->not->toBeNull() ->and($firm->warmup_started_at->isToday())->toBeFalse(); }); it('добавляет новый номер к уже заведённой фирме', function () { $headers = ['X-Sales-Token' => 'test-token']; $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => ['79990000001', '79990000003']]), $headers )->assertOk(); expect(SalesAdAudiencePhone::count())->toBe(3); }); it('отвергает номер не в формате Яндекса', function () { $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => ['+7 (999) 000-00-01']]), ['X-Sales-Token' => 'test-token'] )->assertStatus(422); }); it('фирму без единого номера в прогрев не берём', function () { $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => []]), ['X-Sales-Token' => 'test-token'] )->assertStatus(422); }); it('старый формат channels=both больше не задаёт членство — грузятся все 4 канала loaded', function () { // Фаза 2: channels/ch_* в payload на членство не влияют. Заезд грузит ВСЕ 4 // канала в loaded вне зависимости от подсказки channels. $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['channels' => 'both']), ['X-Sales-Token' => 'test-token'] )->assertOk(); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); it('без указания площадки заезд всё равно грузит все 4 канала loaded', function () { $payload = warmupPayload(); unset($payload['firms'][0]['channels']); $this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token']) ->assertOk(); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); it('ch_yandex/ch_vk/ch_mts в payload больше не влияют на членство — все 4 канала loaded', function () { $payload = warmupPayload(); unset($payload['firms'][0]['channels']); $payload['firms'][0]['ch_yandex'] = false; $payload['firms'][0]['ch_vk'] = true; $payload['firms'][0]['ch_mts'] = true; $this->postJson('/api/sales/integration/ad-audience', $payload, ['X-Sales-Token' => 'test-token']) ->assertOk(); $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); it('отвергает выдуманную площадку', function () { $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['channels' => 'telegram']), ['X-Sales-Token' => 'test-token'] )->assertStatus(422); }); // ── Task 11: оператор номера доезжает из «Поиска клиентов» ─────────────────── // // 🔴 Эти тесты идут ЧЕРЕЗ HTTP намеренно. Проверка формата номера живёт в // SalesIntegrationController, а не в AdAudienceIntake, поэтому тест, дёргающий // сервис напрямую, дыру в валидации НЕ УВИДИТ: он будет зелёным, пока живой путь // отдаёт 422 и не пишет ни строки. Ровно так эта дыра и пряталась. it('принимает номера со словарём через HTTP и сохраняет оператора', function () { $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => [ ['phone' => '79990000021', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'], ]]), ['X-Sales-Token' => 'test-token'] )->assertOk(); $row = SalesAdAudiencePhone::where('phone', '79990000021')->first(); expect($row)->not->toBeNull() ->and($row->operator)->toBe('ВымпелКом') ->and($row->phone_type)->toBe('mobile'); }); it('по-прежнему принимает старый формат — голые строки через HTTP', function () { // Python-служба переезжает не мгновенно: пока часть посылок идёт старым // форматом, приём обязан их брать, а не отбивать 422. $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => ['79990000022']]), ['X-Sales-Token' => 'test-token'] )->assertOk(); $row = SalesAdAudiencePhone::where('phone', '79990000022')->first(); expect($row)->not->toBeNull() ->and($row->operator)->toBeNull(); }); it('отвергает мусор вместо номера в обоих форматах', function () { $headers = ['X-Sales-Token' => 'test-token']; // старый формат — голая строка не по формату Яндекса $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => ['не-телефон']]), $headers )->assertStatus(422); // новый формат — строгость к САМОМУ номеру теряться не должна оттого, // что он приехал завёрнутым в словарь $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => [['phone' => 'не-телефон', 'operator' => 'ВымпелКом']]]), $headers )->assertStatus(422); // словарь вообще без номера $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => [['operator' => 'ВымпелКом']]]), $headers )->assertStatus(422); }); it('отвергает оператора длиннее колонки в базе', function () { // operator VARCHAR(30) / phone_type VARCHAR(12): без ограничения в проверке // длинное значение упало бы уже на записи в БД, а не на входе. $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => [ ['phone' => '79990000023', 'operator' => str_repeat('я', 31)], ]]), ['X-Sales-Token' => 'test-token'] )->assertStatus(422); $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['phones' => [ ['phone' => '79990000023', 'phone_type' => str_repeat('m', 13)], ]]), ['X-Sales-Token' => 'test-token'] )->assertStatus(422); });