Files
portal/app/tests/Feature/ClientTg/CampaignApiTest.php
T
Дмитрий 0c9642f3d0 feat(телеграм-модуль): клиентский экран + ручной запуск в песочнице — API, telegram.ts, экран, сквозной сценарий
Сессия 3 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Клиент сам собирает объявление + аудиторию и жмёт «Запустить»; робот кабинета МТС
(в тестах замокан) доводит до черновика. Всё в песочнице: деньги/живой запуск выключены.

- Клиентский API (Api\ClientTg\CampaignController + маршруты /api/telegram/*):
  создание (store) и запуск (launch) РАЗДЕЛЕНЫ. store создаёт ЧЕРНОВИК со сметой и
  числом кандидатов (деньги/робот не трогаются); launch draft→queued, в бою бронирует
  лимит на объявление (budget_cap_rub) и ставит RunTelegramCampaignJob. Нехватка денег
  → 409 (кампания остаётся черновиком); повторный запуск не-черновика → 422; изоляция
  тенантов (чужой show → 404). Валидация текст/ссылка/аудитория/бюджет; deals без срока → 422.
- Фронт-API telegram.ts (зеркало client-sms.ts, уже): fetch/create/get/launch, cookie-сессия.
- Экран AdvertisingTelegramView.vue (/advertising/telegram): форма (объявление, ссылка,
  3 способа аудитории, лимит), кнопка «Запустить» (create→launch), баннер песочницы,
  список кампаний с человеческим ярлыком статуса, сообщение при нехватке денег.
- Канал «Реклама Телеграм» АКТИВИРОВАН: advertisingChannels.ts route, провод в AppSidebar
  и AppMoreDrawer (route → реальный экран, остальные каналы — прежняя заглушка), маршрут
  в router. Тесты каналов обновлены (телеграм больше не заглушка).
- Ручной сквозной сценарий (песочница): создать → запустить → джоб (робот замокан, draft)
  → draft_ready + matched виден в API.

Отличие от СМС-близнеца: отдельного эндпоинта «предпросчёт до создания» нет — смету и
кандидатов клиент видит в самом черновике (создание черновик денег не тратит).

Проверки: 60/60 Pest ClientTg зелёные (+10 новых), 1512 Vitest зелёные (0 регрессий),
phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений. Робот в тестах замокан
(кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:39:43 +03:00

178 lines
8.6 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\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');
$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);
});