travelTo(CarbonImmutable::parse('2026-08-15 09:00', 'UTC')); // 🪤 Очередь в тестах `sync`: без заглушки джоб отправки поехал бы прямо внутри // теста и мы проверяли бы отправку, а не досыл. Queue::fake(); }); /** * Готовая рассылка с уже известной судьбой каждого номера. * * @param array $fates номер => судьба (null — ещё не спрашивали) * * Ключ описан как int|string намеренно: PHP сам превращает ключ-номер «79995550001» * в целое число, поэтому обещание «только строка» было ложным. Внутри номер всё * равно приводится к строке — поведение не меняется. */ function resendParent(int $tenantId, array $fates, string $status = ClientSmsCampaign::STATUS_DONE): ClientSmsCampaign { $campaign = ClientSmsCampaign::create([ 'tenant_id' => $tenantId, 'title' => 'Первая рассылка', 'body' => 'Здравствуйте! Это первая рассылка.', 'sender_name' => 'LIDERRA', 'source' => ClientSmsCampaign::SOURCE_MANUAL, 'status' => $status, 'idempotency_key' => 'resend-parent-'.uniqid(), 'segments' => 1, 'planned_count' => count($fates), 'sent_count' => count($fates), 'total_sms' => count($fates), 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => '8.50', ]); foreach ($fates as $phone => $fate) { ClientSmsMessage::create([ 'tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'phone' => (string) $phone, 'provider_key' => 'mts', 'status' => ClientSmsMessage::STATUS_SENT, 'delivery_status' => $fate, 'cost_rub' => '8.50', 'segments' => 1, ]); } return $campaign; } /** Клиент с деньгами на кошельке и вошедший в кабинет человек. */ function resendClient(): array { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); app(AdWalletService::class)->topup((int) $tenant->id, '1000.00', null, 'прогон досыла'); return [$tenant, $user]; } it('досыл берёт только НЕ ДОШЕДШИЕ номера и заводит отдельную рассылку', function () { [$tenant, $user] = resendClient(); $parent = resendParent((int) $tenant->id, [ '79995550001' => SmsDeliveryState::DELIVERED, '79995550002' => SmsDeliveryState::NOT_DELIVERED, '79995550003' => SmsDeliveryState::NOT_SENT, ]); $response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend"); $response->assertCreated(); $child = ClientSmsCampaign::where('tenant_id', $tenant->id) ->where('resend_of_campaign_id', $parent->id) ->firstOrFail(); expect($child->title)->toBe("Досыл к рассылке №{$parent->id}"); expect($child->body)->toBe($parent->body); expect((int) $child->planned_count)->toBe(2); // 🔴 Главная проверка: не «сколько», а КТО. Число 2 могло совпасть случайно — // например, если бы досыл взял доставленного и потерял одного недошедшего. expect(ClientSmsCampaignPhone::where('campaign_id', $child->id)->orderBy('phone')->pluck('phone')->all()) ->toBe(['79995550002', '79995550003']); // Старая запись цела: «не доставлено» из отчёта не затирается (В-200). expect(ClientSmsMessage::where('campaign_id', $parent->id)->count())->toBe(3); expect(ClientSmsMessage::where('campaign_id', $parent->id) ->where('phone', '79995550002')->value('delivery_status')) ->toBe(SmsDeliveryState::NOT_DELIVERED); }); it('досыл помнит оператора, которого портал уже знал по первой рассылке', function () { // 🔴 Мина, найденная живым прогоном: начни досыл с чистого листа — номер попал бы // в «оператора не знаем», а такую строку обогащение регионом уже НЕ чинит (оно // берёт только строки без причины отказа). На бою, где универсального канала может // не быть, досыл не ушёл бы НИКОМУ, а деньги за рассылку списались бы. [$tenant, $user] = resendClient(); $parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::NOT_DELIVERED]); ClientSmsMessage::where('campaign_id', $parent->id)->update(['operator' => 'mts']); $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertCreated(); $child = ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->firstOrFail(); expect(ClientSmsCampaignPhone::where('campaign_id', $child->id)->value('operator'))->toBe('mts'); }); it('досылать нечего — говорит человеческими словами и рассылку не заводит', function () { [$tenant, $user] = resendClient(); $parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::DELIVERED]); $response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend"); $response->assertStatus(422); expect($response->json('message'))->toContain('нечего'); expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0); }); it('пока часть номеров ещё в пути — досыл не даём, чтобы не слать дважды', function () { [$tenant, $user] = resendClient(); $parent = resendParent((int) $tenant->id, [ '79995550001' => SmsDeliveryState::NOT_DELIVERED, '79995550002' => SmsDeliveryState::SENDING, // Судьбу этого ещё не спрашивали — для человека это то же «ещё в пути» (В-214). '79995550003' => null, ]); $response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend"); $response->assertStatus(409); expect($response->json('message'))->toContain('в пути'); expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0); }); it('пока сама рассылка ещё идёт — досыл не даём: часть номеров даже не отправлена', function () { // 🔴 Дыра, которой не было в плане: «ещё в пути» считается только по ОТПРАВЛЕННЫМ. // У рассылки в работе остальные номера лежат как `pending` — они в этот счёт не // попадают, и досыл разрешился бы посреди работы, ещё до того как оператор вообще // получил половину списка. [$tenant, $user] = resendClient(); $parent = resendParent( (int) $tenant->id, ['79995550001' => SmsDeliveryState::NOT_DELIVERED], ClientSmsCampaign::STATUS_SENDING, ); ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $parent->id, 'phone' => '79995550002', 'status' => ClientSmsMessage::STATUS_PENDING, 'segments' => 1, ]); $response = $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend"); $response->assertStatus(409); expect($response->json('message'))->toContain('ещё идёт'); expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0); }); it('повторное нажатие не заводит вторую рассылку и не берёт денег дважды', function () { // Это ДЕНЬГИ: двойной клик по «Дослать» означал бы вторую оплаченную рассылку // тем же людям. Защита та же, что у обычного заказа, — ключ заказа (строка 1.18). [$tenant, $user] = resendClient(); $parent = resendParent((int) $tenant->id, [ '79995550001' => SmsDeliveryState::NOT_DELIVERED, '79995550002' => SmsDeliveryState::NOT_SENT, ]); $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertCreated(); $this->actingAs($user)->postJson("/api/sms/campaigns/{$parent->id}/resend")->assertSuccessful(); expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(1); }); it('карточка рассылки сама говорит экрану, показывать ли кнопку досыла и на сколько номеров', function () { // Урок В-146: правило живёт в ОДНОМ месте, на сервере. Экран рисует кнопку по // готовому признаку и условие не пересчитывает — иначе человек нажимал бы кнопку // и получал отказ. [$tenant, $user] = resendClient(); $done = resendParent((int) $tenant->id, [ '79995550001' => SmsDeliveryState::DELIVERED, '79995550002' => SmsDeliveryState::NOT_DELIVERED, '79995550003' => SmsDeliveryState::NOT_SENT, ]); $this->actingAs($user)->getJson("/api/sms/campaigns/{$done->id}") ->assertOk() ->assertJsonPath('resend.can', true) ->assertJsonPath('resend.count', 2); $waiting = resendParent((int) $tenant->id, [ '79995550004' => SmsDeliveryState::NOT_DELIVERED, '79995550005' => SmsDeliveryState::SENDING, ]); $this->actingAs($user)->getJson("/api/sms/campaigns/{$waiting->id}") ->assertOk() ->assertJsonPath('resend.can', false) ->assertJsonPath('resend.count', 0); }); it('чужую рассылку дослать нельзя', function () { [$tenant] = resendClient(); $parent = resendParent((int) $tenant->id, ['79995550001' => SmsDeliveryState::NOT_DELIVERED]); $stranger = Tenant::factory()->create(); $strangerUser = User::factory()->create(['tenant_id' => $stranger->id]); $this->actingAs($strangerUser) ->postJson("/api/sms/campaigns/{$parent->id}/resend") ->assertStatus(404); expect(ClientSmsCampaign::where('resend_of_campaign_id', $parent->id)->count())->toBe(0); });