feat(реклама): пауза/возобновление кампании клиента (Директ suspend/resume)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user