Files
portal/app/tests/Feature/ClientTg/CampaignApiTest.php
T
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):

- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
  телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
  только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
  формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
  max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
  два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
  под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
  config client_tg.auto_batch_threshold (367). В песочнице гейта нет.

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 06:37:25 +03:00

192 lines
9.3 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\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Клиентский HTTP-API Telegram-кампаний (план §Сессия 3, задача 3.1) — тонкий слой
* над готовым ядром (аудитория/тариф/кошелёк) и джобом RunTelegramCampaignJob.
*
* Отличие от СМС-близнеца: создание кампании (`store`) и запуск (`launch`) РАЗДЕЛЕНЫ.
* `store` создаёт ЧЕРНОВИК (деньги/робот не трогаются) — клиент видит смету и число
* кандидатов; отдельный `launch` переводит draft→queued, в бою бронирует лимит на
* объявление (budget_cap_rub) и ставит джоб. Песочница: заморозки нет.
*
* Auth/tenant как у EndpointTest СМС: Tenant::factory + User(tenant_id) + actingAs;
* middleware ['auth:sanctum','tenant']. Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload создания кампании по списку номеров. */
function tgStorePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
it('store создаёт ЧЕРНОВИК со сметой и числом кандидатов; робот/деньги не трогаются', function () {
Queue::fake();
$response = $this->postJson('/api/telegram/campaigns', tgStorePayload());
$response->assertCreated()
->assertJsonPath('status', Campaign::STATUS_DRAFT)
->assertJsonPath('planned_count', 2)
->assertJsonPath('audience_kind', 'list');
expect($response->json('estimated_cost_rub'))->toBeString();
// Черновик: джоб не ставится, кошелёк не создан.
Queue::assertNothingPushed();
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('store подставляет категорию ОРД по умолчанию «Размещение рекламы»', function () {
$this->postJson('/api/telegram/campaigns', tgStorePayload())
->assertCreated()
->assertJsonPath('ord_category', 'Размещение рекламы');
});
it('валидация: без текста/ссылки/аудитории/бюджета — 422', function () {
$this->postJson('/api/telegram/campaigns', [])
->assertStatus(422)
->assertJsonValidationErrors(['ad_text', 'ad_link', 'audience_kind', 'budget_cap_rub']);
});
it('audience_kind=deals без срока (audience_days) отклоняется — не по всей истории', function () {
$this->postJson('/api/telegram/campaigns', tgStorePayload([
'audience_kind' => 'deals',
'phones' => null,
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
});
it('index отдаёт список своих кампаний, песочницу и баланс/заморозку кошелька', function () {
app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test');
Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->getJson('/api/telegram/campaigns')
->assertOk()
->assertJsonPath('sandbox', true)
->assertJsonPath('balance_rub', '100.00')
->assertJsonPath('frozen_rub', '0.00')
->assertJsonCount(1, 'campaigns');
});
it('launch (песочница) переводит draft→queued и ставит джоб; заморозки нет', function () {
Queue::fake();
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
$this->assertDatabaseHas('client_tg_campaigns', [
'id' => $campaign->id, 'status' => Campaign::STATUS_QUEUED,
]);
Queue::assertPushed(RunTelegramCampaignJob::class);
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('launch (реальный режим, денег не хватает) → 409, кампания остаётся черновиком', function () {
config(['client_tg.sandbox' => false]);
Queue::fake();
// Кошелёк есть, но денег меньше лимита на объявление.
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
// Гейт аудитории (находка #2) срабатывает ДО проверки денег: без ≥367
// кандидатов launch вернул бы 422 раньше, чем дойдёт до брони. Этот тест
// проверяет именно путь «денег не хватает», поэтому засеваем аудиторию
// с запасом над порогом client_tg.auto_batch_threshold (367).
for ($i = 0; $i < 367; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(409);
// Осталась черновиком, джоб не поставлен.
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
Queue::assertNothingPushed();
});
it('launch не-черновика (уже queued) → 422, повторно не запускаем', function () {
Queue::fake();
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(422);
Queue::assertNothingPushed();
});
it('изоляция тенантов: чужие кампании не видны в списке и show чужой → 404', function () {
$mine = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'моя', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$tenantB = Tenant::factory()->create();
$foreign = Campaign::create([
'tenant_id' => $tenantB->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'чужая', 'ad_link' => 'https://t.me/y', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$ids = collect($this->getJson('/api/telegram/campaigns')->json('campaigns'))->pluck('id')->all();
expect($ids)->toContain($mine->id)->and($ids)->not->toContain($foreign->id);
$this->getJson("/api/telegram/campaigns/{$mine->id}")->assertOk()
->assertJsonPath('campaign.id', $mine->id);
$this->getJson("/api/telegram/campaigns/{$foreign->id}")->assertStatus(404);
});