257 lines
13 KiB
PHP
257 lines
13 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use App\Jobs\SendClientSmsCampaignJob;
|
|||
|
|
use App\Models\AdWallet;
|
|||
|
|
use App\Models\ClientSmsCampaign;
|
|||
|
|
use App\Models\ClientSmsContact;
|
|||
|
|
use App\Models\ClientSmsMessage;
|
|||
|
|
use App\Models\Tenant;
|
|||
|
|
use App\Models\User;
|
|||
|
|
use App\Services\Advertising\AdWalletService;
|
|||
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
|||
|
|
use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
|||
|
|
use App\Services\ClientSms\ClientSmsPricing;
|
|||
|
|
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
|||
|
|
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
|||
|
|
use App\Services\ClientSms\ClientSmsSnapshotWriter;
|
|||
|
|
use App\Services\Sms\OperatorNormalizer;
|
|||
|
|
use App\Services\Sms\Providers\MtsSmsProvider;
|
|||
|
|
use App\Services\Sms\SmsRouter;
|
|||
|
|
use Carbon\CarbonImmutable;
|
|||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
use Illuminate\Support\Facades\Http;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Строка листа 4.13: кнопка «Продолжить рассылку» (решение владельца В-49 «надо обязательно»).
|
|||
|
|
*
|
|||
|
|
* Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт
|
|||
|
|
* дальше С ТОГО МЕСТА: уже отправленным повторно не шлём, а тому, на кого денег не хватило,
|
|||
|
|
* шлём обязательно.
|
|||
|
|
*
|
|||
|
|
* 🔴 Мина, ради которой этот тест главный (В-151, подтверждена чтением кода): джоб отправки
|
|||
|
|
* считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, БЕЗ
|
|||
|
|
* разбора статуса. А номер, на котором кончились деньги, записан как `skipped_no_funds`.
|
|||
|
|
* Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение,
|
|||
|
|
* а получатель не получил бы ничего. Поэтому продолжение эти строки УДАЛЯЕТ (В-184 — иначе
|
|||
|
|
* не даёт уникальный ключ по клиенту+рассылке+номеру).
|
|||
|
|
*
|
|||
|
|
* Обстановка не изобретена: она взята из `NoFundsStopTest` (там рассылка доводится ровно до
|
|||
|
|
* состояния «встала из-за денег»), помощники переименованы с префиксом resume* — имена
|
|||
|
|
* функций в Pest ГЛОБАЛЬНЫЕ.
|
|||
|
|
*
|
|||
|
|
* Номера только синтетические 7999… — реальные НИКОГДА.
|
|||
|
|
*/
|
|||
|
|
uses(RefreshDatabase::class);
|
|||
|
|
|
|||
|
|
beforeEach(function () {
|
|||
|
|
// Окно 10–20 по местному времени: 09:00 UTC = полдень в Москве, окно открыто.
|
|||
|
|
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** Кампания со снимком цены 8.50 за СМС — как её создаёт контроллер. */
|
|||
|
|
function resumeCampaign(int $tenantId, int $totalSms): ClientSmsCampaign
|
|||
|
|
{
|
|||
|
|
return ClientSmsCampaign::create([
|
|||
|
|
'tenant_id' => $tenantId,
|
|||
|
|
'title' => 'Рассылка',
|
|||
|
|
'body' => 'Здравствуйте!',
|
|||
|
|
'sender_name' => 'liderra.ru',
|
|||
|
|
'source' => ClientSmsCampaign::SOURCE_BASE,
|
|||
|
|
'status' => ClientSmsCampaign::STATUS_QUEUED,
|
|||
|
|
'segments' => 1,
|
|||
|
|
'planned_count' => $totalSms,
|
|||
|
|
'sent_count' => 0,
|
|||
|
|
'total_sms' => $totalSms,
|
|||
|
|
'price_rub_per_sms' => '8.50',
|
|||
|
|
'estimated_cost_rub' => bcmul('8.50', (string) $totalSms, 2),
|
|||
|
|
'actual_cost_rub' => null,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 🪤 В-46: канал выбирается ДВАЖДЫ — подменять надо роутер в контейнере. */
|
|||
|
|
function resumeBindRoute(): void
|
|||
|
|
{
|
|||
|
|
app()->instance(SmsRouter::class, new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resumeSnapshot(ClientSmsCampaign $campaign): void
|
|||
|
|
{
|
|||
|
|
$selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
|
|||
|
|
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
|
|||
|
|
|
|||
|
|
app(ClientSmsSnapshotWriter::class)->write(
|
|||
|
|
(int) $campaign->tenant_id,
|
|||
|
|
(int) $campaign->id,
|
|||
|
|
$selector->build($recipients, (int) $campaign->tenant_id),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resumeRunJob(int $campaignId, int $tenantId, AdWalletService $wallet): void
|
|||
|
|
{
|
|||
|
|
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
|
|||
|
|
app(ClientSmsSnapshotReader::class),
|
|||
|
|
app(ClientSmsPricing::class),
|
|||
|
|
$wallet,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Доводит рассылку на 3 номера до состояния «встала из-за денег»: одно СМС ушло,
|
|||
|
|
* на втором деньги кончились. Возвращает клиента, рассылку и кошелёк.
|
|||
|
|
*/
|
|||
|
|
function resumeStuckByMoney(): array
|
|||
|
|
{
|
|||
|
|
config(['services.sms.sandbox' => false]);
|
|||
|
|
Http::fake(['api.mts.ru/*' => Http::response([
|
|||
|
|
'code' => 0,
|
|||
|
|
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK']]],
|
|||
|
|
], 200)]);
|
|||
|
|
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
$wallet = app(AdWalletService::class);
|
|||
|
|
$wallet->topup($tenant->id, '1000.00', null, 'test');
|
|||
|
|
|
|||
|
|
foreach (['79995550001', '79995550002', '79995550003'] as $i => $phone) {
|
|||
|
|
ClientSmsContact::create([
|
|||
|
|
'tenant_id' => $tenant->id,
|
|||
|
|
'phone' => $phone,
|
|||
|
|
'name' => 'К'.$i,
|
|||
|
|
'operator' => 'МТС',
|
|||
|
|
'tz_offset_minutes' => 180,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resumeBindRoute();
|
|||
|
|
$campaign = resumeCampaign($tenant->id, 3);
|
|||
|
|
resumeSnapshot($campaign);
|
|||
|
|
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '25.50');
|
|||
|
|
|
|||
|
|
// Осталось 12.00 — хватает ровно на одно СМС, на второе уже нет.
|
|||
|
|
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '12.00']);
|
|||
|
|
|
|||
|
|
resumeRunJob($campaign->id, $tenant->id, $wallet);
|
|||
|
|
|
|||
|
|
return [$tenant, $campaign->fresh(), $wallet];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Клиентский пользователь для запросов к адресам кабинета. */
|
|||
|
|
function resumeActor(int $tenantId): User
|
|||
|
|
{
|
|||
|
|
return User::factory()->create(['tenant_id' => $tenantId]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
it('4.13 — продолжение шлёт тому, на ком кончились деньги, и не шлёт повторно уже отправленным', function () {
|
|||
|
|
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
|
|||
|
|
|
|||
|
|
// Убеждаемся, что обстановка та: встала по деньгам, ушло одно, один номер с отказом.
|
|||
|
|
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
|
|||
|
|
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
|
|||
|
|
->and($campaign->sent_count)->toBe(1)
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|||
|
|
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
|
|||
|
|
|
|||
|
|
// Клиент пополнил кошелёк и нажал «Продолжить».
|
|||
|
|
$wallet->topup($tenant->id, '100.00', null, 'пополнение перед продолжением');
|
|||
|
|
|
|||
|
|
$this->actingAs(resumeActor($tenant->id))
|
|||
|
|
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
|
|||
|
|
->assertOk();
|
|||
|
|
|
|||
|
|
resumeRunJob($campaign->id, $tenant->id, $wallet);
|
|||
|
|
|
|||
|
|
$campaign->refresh();
|
|||
|
|
|
|||
|
|
// Ушло всем трём, и у КАЖДОГО номера ровно одна запись в журнале.
|
|||
|
|
expect($campaign->sent_count)->toBe(3)
|
|||
|
|
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(3)
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|||
|
|
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(0)
|
|||
|
|
->and((string) $campaign->actual_cost_rub)->toBe('25.50');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('4.13 — рассылку, остановленную клиентом, продолжить нельзя', function () {
|
|||
|
|
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
|
|||
|
|
|
|||
|
|
// Та же рассылка, но причина остановки — решение человека.
|
|||
|
|
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
|
|||
|
|
|
|||
|
|
$response = $this->actingAs(resumeActor($tenant->id))
|
|||
|
|
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
|
|||
|
|
->assertStatus(422);
|
|||
|
|
|
|||
|
|
// Текст объясняет, ЧЬЁ это было решение: человеку важно понимать, что остановил он сам.
|
|||
|
|
expect($response->json('message'))->toContain('остановили сами');
|
|||
|
|
|
|||
|
|
// Ничего не изменилось: ни статус, ни журнал.
|
|||
|
|
expect(ClientSmsCampaign::whereKey($campaign->id)->value('stop_reason'))->toBe(ClientSmsCampaign::STOP_CLIENT)
|
|||
|
|
->and(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|||
|
|
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('4.13 — рассылку, сорванную сторожем, продолжить нельзя', function () {
|
|||
|
|
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
|
|||
|
|
|
|||
|
|
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_WATCHDOG]);
|
|||
|
|
|
|||
|
|
$response = $this->actingAs(resumeActor($tenant->id))
|
|||
|
|
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
|
|||
|
|
->assertStatus(422);
|
|||
|
|
|
|||
|
|
// Здесь причина другая, и текст обязан быть другим: остановила Лидерра из-за сбоя.
|
|||
|
|
expect($response->json('message'))->toContain('из-за сбоя');
|
|||
|
|
|
|||
|
|
expect(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|||
|
|
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('4.13 — при пустом кошельке продолжение не трогает ни рассылку, ни деньги', function () {
|
|||
|
|
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
|
|||
|
|
|
|||
|
|
// Денег нет вовсе: продолжать нечем, и врать про это нельзя.
|
|||
|
|
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '0.00']);
|
|||
|
|
$movesBefore = DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count();
|
|||
|
|
|
|||
|
|
$response = $this->actingAs(resumeActor($tenant->id))
|
|||
|
|
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
|
|||
|
|
->assertStatus(422);
|
|||
|
|
|
|||
|
|
// Отказ называет ДЕНЬГИ, а не «нельзя продолжить».
|
|||
|
|
expect($response->json('message'))->toContain('₽');
|
|||
|
|
|
|||
|
|
$campaign->refresh();
|
|||
|
|
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
|
|||
|
|
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
|
|||
|
|
// Строку «не хватило денег» не тронули: продолжения не было.
|
|||
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|||
|
|
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1)
|
|||
|
|
->and(DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count())->toBe($movesBefore)
|
|||
|
|
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('0.00');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('4.13 — список рассылок сам говорит, какую можно продолжить и сколько осталось', function () {
|
|||
|
|
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
|
|||
|
|
|
|||
|
|
$rows = $this->actingAs(resumeActor($tenant->id))
|
|||
|
|
->getJson('/api/sms/campaigns')
|
|||
|
|
->assertOk()
|
|||
|
|
->json('campaigns');
|
|||
|
|
|
|||
|
|
$row = collect($rows)->firstWhere('id', $campaign->id);
|
|||
|
|
|
|||
|
|
// Правило живёт на СЕРВЕРЕ (урок В-146): экран не разбирает причины сам.
|
|||
|
|
// Осталось двое: тот, на ком встали, и тот, до кого не дошли.
|
|||
|
|
expect($row['can_resume'])->toBeTrue()
|
|||
|
|
->and($row['resume_pending_count'])->toBe(2);
|
|||
|
|
|
|||
|
|
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
|
|||
|
|
|
|||
|
|
$rows = $this->actingAs(resumeActor($tenant->id))->getJson('/api/sms/campaigns')->json('campaigns');
|
|||
|
|
expect(collect($rows)->firstWhere('id', $campaign->id)['can_resume'])->toBeFalse();
|
|||
|
|
});
|