0 «состаривает» updated_at. */ function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0, string $estimatedCost = '0.00'): Campaign { $c = Campaign::query()->create([ 'tenant_id' => $tenantId, 'status' => $status, 'mts_campaign_id' => $mtsId, 'ad_text' => 'Свип-тест', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_LIST, 'planned_count' => 0, 'estimated_cost_rub' => $estimatedCost, 'created_by' => 1, ]); if ($ageMinutes > 0) { // Прямой апдейт updated_at минуя Eloquent-touch (иначе штамп «сейчас»). DB::table('client_tg_campaigns')->where('id', $c->id) ->update(['updated_at' => now()->subMinutes($ageMinutes)]); } return $c; } /** Тенант с общим балансом + tenant-контекст (`SET LOCAL`) под его id для списаний. */ function sweepTenant(string $balance): Tenant { $t = Tenant::factory()->create(['balance_rub' => $balance]); DB::statement('SET LOCAL app.current_tenant_id = '.$t->id); return $t; } it('константа STATUS_NEEDS_REVIEW есть и равна needs_review', function () { expect(Campaign::STATUS_NEEDS_REVIEW)->toBe('needs_review'); }); it('зависшая queued (деньги списаны, джоб не стартовал) → failed + возврат сметы', function () { config()->set('client_tg.sandbox', false); $tenant = sweepTenant('5000.00'); // queued без mts_campaign_id, состарена больше окна QUEUED_STUCK_AFTER_MINUTES (60). $c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 61, '315.00'); app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685 expect((string) $tenant->fresh()->balance_rub)->toBe('4685.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED) ->and((string) $tenant->fresh()->balance_rub)->toBe('5000.00') ->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND) ->where('related_id', $c->id)->count())->toBe(1); }); it('свежая queued (в пределах окна) не трогается — джоб может её вот-вот запустить', function () { config()->set('client_tg.sandbox', false); $tenant = sweepTenant('5000.00'); $c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 5, '315.00'); app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685 SweepStuckTelegramCampaignsJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_QUEUED) ->and((string) $tenant->fresh()->balance_rub)->toBe('4685.00') // не вернули ->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND) ->where('related_id', $c->id)->count())->toBe(0); }); it('running допускает needs_review, needs_review — терминальный', function () { $t = Tenant::factory()->create(); $c = sweepCampaign($t->id, Campaign::STATUS_RUNNING); expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue(); $c->transitionTo(Campaign::STATUS_NEEDS_REVIEW); expect($c->status)->toBe(Campaign::STATUS_NEEDS_REVIEW) ->and($c->canTransitionTo(Campaign::STATUS_FAILED))->toBeFalse() ->and($c->canTransitionTo(Campaign::STATUS_LAUNCHED))->toBeFalse(); }); it('таймаут джоба (420с) больше робот-таймаута', function () { $job = new RunTelegramCampaignJob(1, 1); expect($job->timeout)->toBe(420); }); it('failed() переводит queued в failed', function () { config()->set('client_tg.sandbox', true); // без возврата $t = Tenant::factory()->create(); $c = sweepCampaign($t->id, Campaign::STATUS_QUEUED); (new RunTelegramCampaignJob($c->id, $t->id))->failed(new RuntimeException('boom')); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED); }); it('зависшая running без mts_campaign_id → failed и деньги вернулись', function () { config()->set('client_tg.sandbox', false); $t = sweepTenant('5000.00'); $c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00'); app(TelegramCampaignChargeService::class)->charge($c, '315.00'); expect((string) $t->fresh()->balance_rub)->toBe('4685.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); // failed + возврат → баланс восстановлен, ровно одна проводка возврата. expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED) ->and((string) $t->fresh()->balance_rub)->toBe('5000.00') ->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND) ->where('related_type', Campaign::class) ->where('related_id', $c->id)->count())->toBe(1); }); it('свежая running не трогается', function () { config()->set('client_tg.sandbox', false); $t = Tenant::factory()->create(); $c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 0); SweepStuckTelegramCampaignsJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_RUNNING); }); it('зависшая running С mts_campaign_id → needs_review, деньги НЕ вернулись', function () { config()->set('client_tg.sandbox', false); $t = sweepTenant('5000.00'); $c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30, '315.00'); app(TelegramCampaignChargeService::class)->charge($c, '315.00'); expect((string) $t->fresh()->balance_rub)->toBe('4685.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); // needs_review, баланс остаётся сниженным (нет возврата), ноль проводок возврата. expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW) ->and((string) $t->fresh()->balance_rub)->toBe('4685.00') ->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND) ->where('related_id', $c->id)->count())->toBe(0); }); it('зависшая moderating → needs_review, деньги НЕ вернулись', function () { config()->set('client_tg.sandbox', false); config()->set('client_tg.moderation_stuck_hours', 1); $t = sweepTenant('5000.00'); $c = sweepCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', 120, '315.00'); app(TelegramCampaignChargeService::class)->charge($c, '315.00'); expect((string) $t->fresh()->balance_rub)->toBe('4685.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); // needs_review, деньги вслепую не возвращаем (кампания могла показываться). expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW) ->and((string) $t->fresh()->balance_rub)->toBe('4685.00') ->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND) ->where('related_id', $c->id)->count())->toBe(0); }); it('песочница: зависшая running → failed, денег не касаемся', function () { config()->set('client_tg.sandbox', true); $t = sweepTenant('5000.00'); // В песочнице деньги не списывали — charge отсутствует. $c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); // Статус добит, но возврат не запускается (нечего и незачем) — 0 проводок, баланс цел. expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED) ->and((string) $t->fresh()->balance_rub)->toBe('5000.00') ->and(BalanceTransaction::where('related_id', $c->id)->count())->toBe(0); });