2026-07-25 10:26:08 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\AdCampaign;
|
2026-07-28 06:36:11 +03:00
|
|
|
|
use App\Models\AdWallet;
|
2026-07-25 10:26:08 +03:00
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use App\Models\User;
|
2026-07-28 06:36:11 +03:00
|
|
|
|
use App\Services\Advertising\AdWalletService;
|
2026-07-25 10:26:08 +03:00
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Пауза/возобновление кампании клиента (Директ suspend/resume).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Мирроим auth/tenant setup из AdvertisingCampaignEndpointTest.php — без RefreshDatabase,
|
|
|
|
|
|
* каждый тест создаёт свой Tenant::factory()->create().
|
|
|
|
|
|
*/
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
|
$this->tenant = Tenant::factory()->create();
|
|
|
|
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
|
|
|
|
$this->actingAs($this->user);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('returns 401 for pause without auth', function () {
|
|
|
|
|
|
auth()->logout();
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson('/api/advertising/campaigns/1/pause')->assertStatus(401);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('returns 401 for resume without auth', function () {
|
|
|
|
|
|
auth()->logout();
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson('/api/advertising/campaigns/1/resume')->assertStatus(401);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pauses a running campaign without calling Direct when disabled', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания на паузу',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk()->assertJsonPath('status', 'paused');
|
|
|
|
|
|
|
|
|
|
|
|
$this->assertDatabaseHas('ad_campaigns', [
|
|
|
|
|
|
'id' => $campaign->id,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 06:36:11 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 🪤 Мина со стороны «включено»: рубильник, заданный в .env строкой («1», «true»),
|
|
|
|
|
|
* приходит из env строкой. Проверка `!== true` считала бы такой рубильник ВЫКЛЮЧЕННЫМ и
|
|
|
|
|
|
* молча не шла в Директ, а пауза приняла бы это за успех и разморозила деньги — при том,
|
|
|
|
|
|
* что реклама в Яндексе продолжает крутиться.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('goes to Direct when the switch is on as a string', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => '1']);
|
|
|
|
|
|
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
|
|
|
|
|
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
|
|
|
|
|
Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Рубильник строкой',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
'yandex_campaign_id' => 555,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/json/v5/campaigns'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Пауза, которая не дошла до Директа, — это не пауза.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Ошибку Директа портал глотал в журнал, ставил кампании статус «на паузе» и БЕЗУСЛОВНО
|
|
|
|
|
|
* размораживал деньги. Итог: реклама в Яндексе продолжает крутиться и тратить, портал
|
|
|
|
|
|
* показывает «на паузе», а деньги за неё уже свободны и могут уйти на другую кампанию.
|
|
|
|
|
|
* Клиент уходит в минус молча.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Честнее отказать: «не удалось остановить, попробуйте ещё раз».
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('refuses to pause and keeps the money frozen when Direct rejects the suspend', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
|
|
|
|
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
|
|
|
|
|
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
|
|
|
|
|
Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Директ лёг']], 500)]);
|
|
|
|
|
|
|
|
|
|
|
|
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Пауза не дошла до Яндекса',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
'yandex_campaign_id' => 555,
|
|
|
|
|
|
'paid_impressions' => 10000,
|
|
|
|
|
|
'client_cpm_rub' => '120.00',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
app(AdWalletService::class)
|
|
|
|
|
|
->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '1200.00');
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")
|
|
|
|
|
|
->assertStatus(409);
|
|
|
|
|
|
|
|
|
|
|
|
// Статус не сменился, деньги остались зарезервированными под работающую рекламу.
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
|
|
|
|
|
expect($wallet->frozen_rub)->toBe('1200.00');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-25 10:26:08 +03:00
|
|
|
|
it('pauses a pending_moderation campaign and calls Direct suspend when enabled', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
|
|
|
|
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
|
|
|
|
|
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
|
|
|
|
|
Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания на модерации',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PENDING_MODERATION,
|
|
|
|
|
|
'yandex_campaign_id' => 555,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk()->assertJsonPath('status', 'paused');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
|
return str_contains($request->url(), '/json/v5/campaigns')
|
|
|
|
|
|
&& ($request['method'] ?? null) === 'suspend'
|
|
|
|
|
|
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 555;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$this->assertDatabaseHas('ad_campaigns', [
|
|
|
|
|
|
'id' => $campaign->id,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('rejects pausing a draft campaign with 409', function () {
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Черновик',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_DRAFT,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(409)
|
|
|
|
|
|
->assertJsonPath('message', 'Кампанию нельзя поставить на паузу из текущего состояния.');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('returns 404 pausing another tenant campaign', function () {
|
|
|
|
|
|
$tenantB = Tenant::factory()->create();
|
|
|
|
|
|
$campaignB = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $tenantB->id,
|
|
|
|
|
|
'name' => 'Чужая кампания',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/pause")->assertStatus(404);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('resumes a paused campaign and calls Direct resume when enabled', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
|
|
|
|
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
|
|
|
|
|
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
|
|
|
|
|
Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['ResumeResults' => [['Id' => 777]]]])]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания на паузе',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
'yandex_campaign_id' => 777,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk()->assertJsonPath('status', 'running');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
|
return str_contains($request->url(), '/json/v5/campaigns')
|
|
|
|
|
|
&& ($request['method'] ?? null) === 'resume'
|
|
|
|
|
|
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 777;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
$this->assertDatabaseHas('ad_campaigns', [
|
|
|
|
|
|
'id' => $campaign->id,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('resumes a paused campaign without calling Direct when disabled', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания без Директа',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk()->assertJsonPath('status', 'running');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('rejects resuming a running campaign with 409', function () {
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Уже идёт',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(409)
|
|
|
|
|
|
->assertJsonPath('message', 'Возобновить можно только кампанию на паузе.');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('returns 404 resuming another tenant campaign', function () {
|
|
|
|
|
|
$tenantB = Tenant::factory()->create();
|
|
|
|
|
|
$campaignB = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $tenantB->id,
|
|
|
|
|
|
'name' => 'Чужая кампания на паузе',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404);
|
|
|
|
|
|
});
|
2026-07-27 09:44:12 +03:00
|
|
|
|
|
|
|
|
|
|
// ── ВЫХОД 4: пауза снимает заморозку, возобновление морозит остаток заново ──
|
|
|
|
|
|
// Решение владельца 27.07.2026: на паузе деньги клиента свободны — он может
|
|
|
|
|
|
// потратить их на другую рекламу; при возобновлении резервируем остаток сметы.
|
|
|
|
|
|
|
|
|
|
|
|
it('ВЫХОД 4: пауза возвращает заморозку в свободные деньги', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
2026-07-28 06:36:11 +03:00
|
|
|
|
$svc = app(AdWalletService::class);
|
2026-07-27 09:44:12 +03:00
|
|
|
|
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания на паузу с заморозкой',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
|
|
|
|
'estimated_impressions' => 10000,
|
|
|
|
|
|
'paid_impressions' => 10000,
|
|
|
|
|
|
'charged_client_rub' => '200.00',
|
|
|
|
|
|
]);
|
|
|
|
|
|
// Остаток сметы: 10000 показов × 120 ₽/1000 = 1200 ₽, из них 200 ₽ уже списано.
|
|
|
|
|
|
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00');
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
|
|
|
|
|
|
|
2026-07-28 06:36:11 +03:00
|
|
|
|
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00');
|
2026-07-27 09:44:12 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
2026-07-28 06:36:11 +03:00
|
|
|
|
$svc = app(AdWalletService::class);
|
2026-07-27 09:44:12 +03:00
|
|
|
|
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания к возобновлению',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
'estimated_impressions' => 10000,
|
|
|
|
|
|
'paid_impressions' => 10000,
|
|
|
|
|
|
'charged_client_rub' => '200.00',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk();
|
|
|
|
|
|
|
2026-07-28 06:36:11 +03:00
|
|
|
|
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00');
|
2026-07-27 09:44:12 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () {
|
|
|
|
|
|
config(['services.yandex_direct.enabled' => false]);
|
2026-07-28 06:36:11 +03:00
|
|
|
|
$svc = app(AdWalletService::class);
|
2026-07-27 09:44:12 +03:00
|
|
|
|
$svc->topup($this->tenant->id, '500.00', 'yandex', 'тест'); // меньше остатка 1000 ₽
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Кампания без денег на возобновление',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'status' => AdCampaign::STATUS_PAUSED,
|
|
|
|
|
|
'estimated_impressions' => 10000,
|
|
|
|
|
|
'paid_impressions' => 10000,
|
|
|
|
|
|
'charged_client_rub' => '200.00',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")
|
|
|
|
|
|
->assertStatus(409)
|
|
|
|
|
|
->assertJsonPath('message', 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_PAUSED);
|
|
|
|
|
|
});
|