create([ 'tenant_id' => $tenantId, 'status' => $status, 'mts_campaign_id' => $mtsId, // Факт с кассы кабинета: столько кампания стоила НАМ. Клиенту 150 × 1,40 = 210 ₽. 'mts_cost_rub' => $mtsCost, 'ad_text' => 'Опрос-тест', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_LIST, 'planned_count' => 0, 'estimated_cost_rub' => $estimatedCost, 'created_by' => 1, ]); } /** «Уже заморожено при запуске»: морозит смету кампании на рекламном кошельке. */ function pollChargeCampaign(Tenant $tenant, Campaign $campaign): void { DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); tgKoshelekTopup($tenant->id, '5000.00'); app(TelegramCampaignChargeService::class)->freeze($campaign, (string) $campaign->estimated_cost_rub); } /** Подменяет робота-читалку мок-результатом. */ function pollMockRobot(RobotResult $result): void { $mock = Mockery::mock(TelegramRobotRunner::class); $mock->shouldReceive('readModeration')->andReturn($result); app()->instance(TelegramRobotRunner::class, $mock); } /** Мок NotificationService для проверки, какое уведомление ушло. */ function pollMockNotifications(): MockInterface { $mock = Mockery::mock(NotificationService::class); app()->instance(NotificationService::class, $mock); return $mock; } it('RobotResult знает moderationStatus и разбирает его из JSON робота', function () { $r = RobotResult::fromRobotJson(['ok' => true, 'moderationStatus' => 'rejected', 'reason' => 'нельзя']); expect($r->moderationStatus)->toBe('rejected') ->and($r->reason)->toBe('нельзя'); // Отсутствие поля → null (робот его пока не прислал). expect(RobotResult::fromRobotJson(['ok' => true])->moderationStatus)->toBeNull(); }); it('вердикт «отклонена» → rejected + причина, заморозка отпущена целиком, уведомление «отклонена»', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(['balance_rub' => '5000.00']); $c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231134', '315.00'); // Уже заморожено при запуске кампании → смета занята. pollChargeCampaign($t, $c); expect(tgKoshelekFrozen($t->id))->toBe('315.00'); pollMockRobot(new RobotResult(ok: true, moderationStatus: 'rejected', reason: 'Ссылка недоступна')); $notify = pollMockNotifications(); $notify->shouldReceive('notifyTelegramCampaignRejected')->once(); $notify->shouldReceive('notifyTelegramCampaignApproved')->never(); PollTelegramModerationJob::dispatchSync(); $fresh = Campaign::find($c->id); expect($fresh->status)->toBe(Campaign::STATUS_REJECTED) ->and($fresh->status_reason)->toBe('Ссылка недоступна') // Отказ отпустил всю заморозку — ни рубля не списано. ->and(tgKoshelekFrozen($t->id))->toBe('0.00') ->and(tgKoshelekBalance($t->id))->toBe('5000.00'); }); it('вердикт «одобрена» → launched, списание ПО ФАКТУ, остаток сметы отпущен', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(['balance_rub' => '5000.00']); $c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231140', '315.00'); // Заморожено при запуске; одобрение превращает заморозку в списание по факту. pollChargeCampaign($t, $c); pollMockRobot(new RobotResult(ok: true, moderationStatus: 'approved')); $notify = pollMockNotifications(); $notify->shouldReceive('notifyTelegramCampaignApproved')->once(); $notify->shouldReceive('notifyTelegramCampaignRejected')->never(); PollTelegramModerationJob::dispatchSync(); // Списано 150 × 1,40 = 210 ₽ (факт кабинета), а не 315 ₽ сметы по размеру списка. expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_LAUNCHED) ->and((string) Campaign::find($c->id)->actual_cost_rub)->toBe('210.00') ->and(tgKoshelekBalance($t->id))->toBe('4790.00') ->and(tgKoshelekFrozen($t->id))->toBe('0.00'); }); it('вердикт «ещё на модерации» → остаётся moderating, без уведомлений и без денег', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(['balance_rub' => '5000.00']); $c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', '315.00'); pollChargeCampaign($t, $c); pollMockRobot(new RobotResult(ok: true, moderationStatus: 'moderating')); $notify = pollMockNotifications(); $notify->shouldReceive('notifyTelegramCampaignRejected')->never(); $notify->shouldReceive('notifyTelegramCampaignApproved')->never(); PollTelegramModerationJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING) // Ни списания, ни разморозки — смета как была занята, так и осталась. ->and(tgKoshelekFrozen($t->id))->toBe('315.00') ->and(tgKoshelekBalance($t->id))->toBe('5000.00'); }); it('робот не смог прочитать вердикт (moderationStatus=null) → moderating без изменений', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(); $c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231160'); pollMockRobot(new RobotResult(ok: false, reason: 'кабинет не открылся')); $notify = pollMockNotifications(); $notify->shouldReceive('notifyTelegramCampaignRejected')->never(); $notify->shouldReceive('notifyTelegramCampaignApproved')->never(); PollTelegramModerationJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING); }); it('moderating без mts_campaign_id не опрашивается (робот не зовётся)', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(); $c = pollCampaign($t->id, Campaign::STATUS_MODERATING, null); // Робот-мок вернул бы approved, но кампанию без id опросчик даже не берёт. pollMockRobot(new RobotResult(ok: true, moderationStatus: 'approved')); $notify = pollMockNotifications(); $notify->shouldReceive('notifyTelegramCampaignApproved')->never(); PollTelegramModerationJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING); });