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, ]); }); /** * 🪤 Мина со стороны «включено»: рубильник, заданный в .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'); }); 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); }); // ── ВЫХОД 4: пауза снимает заморозку, возобновление морозит остаток заново ── // Решение владельца 27.07.2026: на паузе деньги клиента свободны — он может // потратить их на другую рекламу; при возобновлении резервируем остаток сметы. it('ВЫХОД 4: пауза возвращает заморозку в свободные деньги', function () { config(['services.yandex_direct.enabled' => false]); $svc = app(AdWalletService::class); $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(); expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00'); }); it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () { config(['services.yandex_direct.enabled' => false]); $svc = app(AdWalletService::class); $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(); expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00'); }); it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () { config(['services.yandex_direct.enabled' => false]); $svc = app(AdWalletService::class); $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); });