tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** * Отклонённая кампания с причиной и id кабинета (self-contained). * * 🪤 Приставка `tg` не для красоты: помощники Pest — ГЛОБАЛЬНЫЕ функции, и рекламный * модуль объявляет свой `rejectedCampaign()` (CampaignReviveServiceTest). При сведении * веток полный прогон падал фаталом «Cannot redeclare» — каждая ветка по отдельности * этого увидеть не могла. */ function tgRejectedCampaign(int $tenantId, int $userId, array $overrides = []): Campaign { return Campaign::create(array_merge([ 'tenant_id' => $tenantId, 'status' => Campaign::STATUS_REJECTED, 'status_reason' => 'Ссылка недоступна', 'mts_campaign_id' => '2231134', 'ad_text' => 'Старый текст', 'ad_link' => 'https://t.me/old_channel', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 400, 'estimated_cost_rub' => '0.00', 'created_by' => $userId, ], $overrides)); } /** @return array валидный payload пересдачи. */ function resubmitPayload(array $overrides = []): array { return array_merge([ 'ad_text' => 'Исправленный текст, ООО «Ромашка»', 'ad_link' => 'https://t.me/new_channel', ], $overrides); } it('пересдача (песочница): rejected→queued, правки применены, причина очищена, id кабинета СОХРАНЁН, джоб поставлен', function () { Queue::fake(); $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertOk() ->assertJsonPath('status', Campaign::STATUS_QUEUED); $fresh = Campaign::find($c->id); expect($fresh->status)->toBe(Campaign::STATUS_QUEUED) ->and($fresh->ad_text)->toBe('Исправленный текст, ООО «Ромашка»') ->and($fresh->ad_link)->toBe('https://t.me/new_channel') ->and($fresh->status_reason)->toBeNull() // Чиним ту же кампанию в кабинете — id НЕ очищаем (связка mode:'resubmit'). ->and($fresh->mts_campaign_id)->toBe('2231134'); Queue::assertPushed(ResubmitTelegramCampaignJob::class); // Песочница — заморозки нет. expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse(); }); it('пересдать нельзя, если кампания не заведена в кабинете (нет mts_campaign_id) → 422, джоб не ставится', function () { Queue::fake(); // Отклонённая, но без id кабинета — «Исправить» нечего (край; в норме id всегда есть). $c = tgRejectedCampaign($this->tenant->id, $this->user->id, ['mts_campaign_id' => null]); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertStatus(422); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_REJECTED); Queue::assertNothingPushed(); }); it('пересдача с файлом модератору: файл сохранён, путь записан в moderator_file_path', function () { Queue::fake(); Storage::fake('local'); $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $file = UploadedFile::fake()->create('licenziya.pdf', 120, 'application/pdf'); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([ 'moderator_file' => $file, ]))->assertOk(); $path = Campaign::find($c->id)->moderator_file_path; expect($path)->toBeString()->not->toBeEmpty(); Storage::disk('local')->assertExists($path); }); it('пересдать можно только отклонённую: черновик → 422, джоб не ставится', function () { Queue::fake(); $c = tgRejectedCampaign($this->tenant->id, $this->user->id, [ 'status' => Campaign::STATUS_DRAFT, 'status_reason' => null, 'mts_campaign_id' => null, ]); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertStatus(422); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_DRAFT); Queue::assertNothingPushed(); }); it('пересдача (реальный режим, денег не хватает) → 409, кампания остаётся rejected, джоб не ставится', function () { config(['client_tg.sandbox' => false]); Queue::fake(); app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test'); // Гейт аудитории считает кандидатов до брони — засеваем с запасом над порогом. for ($i = 0; $i < 367; $i++) { Contact::create([ 'tenant_id' => $this->tenant->id, 'phone' => sprintf('7999%07d', $i), 'name' => null, 'operator' => null, ]); } $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertStatus(409); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_REJECTED); Queue::assertNothingPushed(); }); it('валидация: без текста/ссылки — 422', function () { $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", []) ->assertStatus(422) ->assertJsonValidationErrors(['ad_text', 'ad_link']); }); it('неверный формат файла (.exe) → 422', function () { Storage::fake('local'); $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([ 'moderator_file' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'), ]))->assertStatus(422)->assertJsonValidationErrors('moderator_file'); }); it('изоляция тенанта: пересдача чужой кампании → 404', function () { $tenantB = Tenant::factory()->create(); $foreign = tgRejectedCampaign($tenantB->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$foreign->id}/resubmit", resubmitPayload()) ->assertStatus(404); });