From bdc1d0947574ee0dcffc6dc21fa7e1d3c883f8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 25 Jul 2026 10:26:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?= =?UTF-8?q?):=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0/=D0=B2=D0=BE=D0=B7=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20(=D0=94=D0=B8=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=20suspend/resume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/AdvertisingCampaignController.php | 66 ++++++ app/routes/web.php | 2 + .../AdvertisingCampaignPauseResumeTest.php | 193 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php index 49b8122a..6f0bf150 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -16,6 +16,7 @@ use App\Services\Advertising\YandexDirectClient; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Log; use RuntimeException; /** @@ -165,6 +166,71 @@ class AdvertisingCampaignController extends Controller return response()->json(['status' => $campaign->fresh()->status]); } + public function pause(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if (! in_array($campaign->status, [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION], true)) { + return response()->json([ + 'message' => 'Кампанию нельзя поставить на паузу из текущего состояния.', + ], 409); + } + + $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId)); + + $campaign->update(['status' => AdCampaign::STATUS_PAUSED]); + + return response()->json(['status' => $campaign->fresh()->status]); + } + + public function resume(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if ($campaign->status !== AdCampaign::STATUS_PAUSED) { + return response()->json([ + 'message' => 'Возобновить можно только кампанию на паузе.', + ], 409); + } + + $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId)); + + $campaign->update(['status' => AdCampaign::STATUS_RUNNING]); + + return response()->json(['status' => $campaign->fresh()->status]); + } + + /** + * Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть + * yandex_campaign_id. Деньги не трогает. Если Директ недоступен — логируем и + * всё равно продолжаем менять локальный статус (клиент ждёт паузу/возобновление + * здесь и сейчас, синхронизация с Директом — не блокер). + */ + private function callDirect(AdCampaign $campaign, callable $action): void + { + if (config('services.yandex_direct.enabled') !== true || $campaign->yandex_campaign_id === null) { + return; + } + + $direct = new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + + try { + $action($direct, (int) $campaign->yandex_campaign_id); + } catch (RuntimeException $e) { + Log::warning('advertising.campaign_direct_call_failed', [ + 'campaign_id' => $campaign->id, + 'error' => $e->getMessage(), + ]); + } + } + public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse { $tenantId = (int) $request->user()->tenant_id; diff --git a/app/routes/web.php b/app/routes/web.php index 2beb3c3c..2e3f8336 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -389,6 +389,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id'); Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id'); Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id'); + Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id'); + Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id'); Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id'); Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']); }); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php new file mode 100644 index 00000000..79baf524 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php @@ -0,0 +1,193 @@ +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, + ]); +}); + +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); +});