tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** @return array валидный payload создания кампании по списку номеров. */ function tgStorePayload(array $overrides = []): array { return array_merge([ 'ad_text' => 'Приходите к нам в канал', 'ad_link' => 'https://t.me/example_channel', 'audience_kind' => 'list', 'budget_cap_rub' => '1000.00', 'phones' => ['79990000001', '79990000002'], ], $overrides); } it('store создаёт ЧЕРНОВИК со сметой и числом кандидатов; робот/деньги не трогаются', function () { Queue::fake(); $response = $this->postJson('/api/telegram/campaigns', tgStorePayload()); $response->assertCreated() ->assertJsonPath('status', Campaign::STATUS_DRAFT) ->assertJsonPath('planned_count', 2) ->assertJsonPath('audience_kind', 'list'); expect($response->json('estimated_cost_rub'))->toBeString(); // Черновик: джоб не ставится, деньги не списываются. Queue::assertNothingPushed(); expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0); }); it('store подставляет категорию ОРД по умолчанию «Размещение рекламы»', function () { $this->postJson('/api/telegram/campaigns', tgStorePayload()) ->assertCreated() ->assertJsonPath('ord_category', 'Размещение рекламы'); }); it('валидация: без текста/ссылки/аудитории/бюджета — 422', function () { $this->postJson('/api/telegram/campaigns', []) ->assertStatus(422) ->assertJsonValidationErrors(['ad_text', 'ad_link', 'audience_kind', 'budget_cap_rub']); }); it('audience_kind=deals без срока (audience_days) отклоняется — не по всей истории', function () { $this->postJson('/api/telegram/campaigns', tgStorePayload([ 'audience_kind' => 'deals', 'phones' => null, ]))->assertStatus(422)->assertJsonValidationErrors('audience_days'); }); it('index отдаёт список своих кампаний, песочницу и общий баланс тенанта', function () { $this->tenant->update(['balance_rub' => '100.00']); Campaign::create([ 'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT, 'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $this->getJson('/api/telegram/campaigns') ->assertOk() ->assertJsonPath('sandbox', true) ->assertJsonPath('balance_rub', '100.00') ->assertJsonPath('frozen_rub', '0.00') ->assertJsonCount(1, 'campaigns'); }); it('launch (песочница) переводит draft→queued и ставит джоб; заморозки нет', function () { Queue::fake(); $campaign = Campaign::create([ 'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT, 'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $this->postJson("/api/telegram/campaigns/{$campaign->id}/launch") ->assertOk() ->assertJsonPath('status', Campaign::STATUS_QUEUED); $this->assertDatabaseHas('client_tg_campaigns', [ 'id' => $campaign->id, 'status' => Campaign::STATUS_QUEUED, ]); Queue::assertPushed(RunTelegramCampaignJob::class); // Песочница: денег не списывали. expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0); }); it('launch (реальный режим, денег не хватает) → 409, кампания остаётся черновиком', function () { config(['client_tg.sandbox' => false]); Queue::fake(); // На балансе меньше клиентской сметы кампании. $this->tenant->update(['balance_rub' => '1.00']); // Гейт аудитории (находка #2) срабатывает ДО проверки денег: без ≥367 // кандидатов launch вернул бы 422 раньше, чем дойдёт до списания. Этот тест // проверяет именно путь «денег не хватает», поэтому засеваем аудиторию // с запасом над порогом client_tg.auto_batch_threshold (367). for ($i = 0; $i < 367; $i++) { Contact::create([ 'tenant_id' => $this->tenant->id, 'phone' => sprintf('7999%07d', $i), 'name' => null, 'operator' => null, ]); } $campaign = Campaign::create([ 'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT, 'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(409); // Осталась черновиком, джоб не поставлен. expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT); Queue::assertNothingPushed(); }); it('launch не-черновика (уже queued) → 422, повторно не запускаем', function () { Queue::fake(); $campaign = Campaign::create([ 'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_QUEUED, 'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(422); Queue::assertNothingPushed(); }); it('изоляция тенантов: чужие кампании не видны в списке и show чужой → 404', function () { $mine = Campaign::create([ 'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT, 'ad_text' => 'моя', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $tenantB = Tenant::factory()->create(); $foreign = Campaign::create([ 'tenant_id' => $tenantB->id, 'status' => Campaign::STATUS_DRAFT, 'ad_text' => 'чужая', 'ad_link' => 'https://t.me/y', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE, 'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id, ]); $ids = collect($this->getJson('/api/telegram/campaigns')->json('campaigns'))->pluck('id')->all(); expect($ids)->toContain($mine->id)->and($ids)->not->toContain($foreign->id); $this->getJson("/api/telegram/campaigns/{$mine->id}")->assertOk() ->assertJsonPath('campaign.id', $mine->id); $this->getJson("/api/telegram/campaigns/{$foreign->id}")->assertStatus(404); });