true]); Queue::fake(); }); afterEach(function () { Carbon::setTestNow(); }); /** Рассылка в нужном состоянии и с нужным «возрастом» последнего движения. */ function stuckCampaign( int $tenantId, string $status, string $updatedAt, ?string $retriedAt = null, ): ClientSmsCampaign { $campaign = ClientSmsCampaign::create([ 'tenant_id' => $tenantId, 'title' => 'Проверка сторожа', 'body' => 'Текст', 'sender_name' => 'LIDERRA', 'source' => ClientSmsCampaign::SOURCE_MANUAL, 'status' => $status, 'idempotency_key' => 'stuck-'.uniqid(), 'segments' => 1, 'planned_count' => 2, 'sent_count' => 0, 'total_sms' => 2, 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => '17.00', 'watchdog_retried_at' => $retriedAt, ]); // updated_at правим запросом: модель перезаписала бы его своим временем. ClientSmsCampaign::whereKey($campaign->id)->update(['updated_at' => $updatedAt]); return $campaign->fresh(); } it('4.1 — рассылка без движения дольше срока сперва кладётся в очередь ещё раз, а не срывается', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00'); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertPushed(SendClientSmsCampaignJob::class, 1); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_SENDING) ->and($campaign->watchdog_retried_at)->not->toBeNull(); }); it('4.1 — рассылка, не сдвинувшаяся и после попытки дожать, срывается с причиной «сторож»', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign( $tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00', '2026-07-29 10:00:00', ); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertNothingPushed(); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED) ->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_WATCHDOG); }); it('4.1 — попытке дожать дают время: свежая попытка рассылку не срывает', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign( $tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00', '2026-07-29 11:50:00', ); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertNothingPushed(); expect($campaign->fresh()->status)->toBe(ClientSmsCampaign::STATUS_SENDING); }); it('4.2 — рассылка, честно ждущая утра, сторожем НЕ трогается', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_WAITING_WINDOW, '2026-07-25 09:00:00'); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertNothingPushed(); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW) ->and($campaign->watchdog_retried_at)->toBeNull(); }); it('4.1 — движение считается по журналу: свежая запись спасает рассылку от сторожа', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00'); ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000001', 'status' => ClientSmsMessage::STATUS_FAKE_SENT, 'cost_rub' => '0.00', 'segments' => 1, ]); ClientSmsMessage::where('campaign_id', $campaign->id) ->update(['created_at' => '2026-07-29 11:50:00']); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertNothingPushed(); expect($campaign->fresh()->status)->toBe(ClientSmsCampaign::STATUS_SENDING); }); it('4.1 — срок берётся из настроек, а не из кода', function () { $tenant = Tenant::factory()->create(); ClientSmsSettings::query()->update(['stuck_after_minutes' => 600]); $campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00'); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); Queue::assertNothingPushed(); expect($campaign->fresh()->watchdog_retried_at)->toBeNull(); }); it('4.1 — сорванная рассылка отдаёт замороженные деньги обратно', function () { config(['services.sms.sandbox' => false]); $tenant = Tenant::factory()->create(); $wallet = app(AdWalletService::class); $wallet->topup($tenant->id, '1000.00', null, 'test'); $campaign = stuckCampaign( $tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00', '2026-07-29 10:00:00', ); $wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); expect((string) AdWallet::where('tenant_id', $tenant->id)->value('frozen_rub'))->toBe('17.00'); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); expect((string) AdWallet::where('tenant_id', $tenant->id)->value('frozen_rub'))->toBe('0.00'); }); it('4.1 — итог сорванной рассылки честный: сколько в журнале, столько и отправлено', function () { $tenant = Tenant::factory()->create(); $campaign = stuckCampaign( $tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00', '2026-07-29 10:00:00', ); foreach (['79990000001', '79990000002'] as $phone) { ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => $phone, 'status' => ClientSmsMessage::STATUS_SENT, 'cost_rub' => '8.50', 'segments' => 1, ]); } // Записи старые — иначе рассылка считалась бы живой и сторож её не тронул бы. ClientSmsMessage::where('campaign_id', $campaign->id) ->update(['created_at' => '2026-07-29 09:30:00']); $this->artisan('client-sms:watch-stuck')->assertSuccessful(); $campaign->refresh(); expect($campaign->sent_count)->toBe(2) ->and((string) $campaign->actual_cost_rub)->toBe('17.00'); });