2026-07-28 19:28:14 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\EnrichClientSmsSnapshotRegionJob;
|
|
|
|
|
|
use App\Models\AdWallet;
|
|
|
|
|
|
use App\Models\ClientSmsCampaign;
|
|
|
|
|
|
use App\Models\ClientSmsCampaignPhone;
|
|
|
|
|
|
use App\Models\ClientSmsMessage;
|
|
|
|
|
|
use App\Models\Deal;
|
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
|
use App\Services\Advertising\AdWalletService;
|
2026-07-29 15:25:37 +03:00
|
|
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
2026-07-28 19:28:14 +03:00
|
|
|
|
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
|
|
|
|
|
use App\Services\ClientSms\SmsQuietHours;
|
|
|
|
|
|
use App\Services\DaData\DaDataBudgetGuard;
|
|
|
|
|
|
use App\Services\DaData\DaDataPhoneClient;
|
|
|
|
|
|
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\Cache;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Строка листа 3.3 до конца: про номер, вписанный руками, мы не знаем НИЧЕГО —
|
|
|
|
|
|
* ни в каком он регионе, ни чей он. И то, и другое спрашиваем у ДаДаты одним
|
|
|
|
|
|
* вопросом (решение владельца В-88), а до ответа номер не уходит и не теряется:
|
|
|
|
|
|
* он ждёт уточнения прямо в снимке (моё решение В-89).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Не узнали за сутки — окончательная причина «не знаем, в каком регионе номер»,
|
|
|
|
|
|
* и рассылка на этом честно заканчивается: деньги размораживаются, причина видна.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Тесты парные (урок В-54): рядом с «не ушло» обязан стоять прогон «а вот теперь
|
|
|
|
|
|
* ушло», иначе ноль мог получиться просто от поломки.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Помощники с префиксом snapRegion* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|
|
|
|
|
* 🪤 SharesSupplierPdo — команда добора перечисляет рассылки служебным соединением
|
|
|
|
|
|
* (BYPASSRLS): без общего PDO оно не видит незакоммиченные данные теста (В-95).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Синтетические номера 7999… — реальные НИКОГДА. ДаДата всюду заглушена: живой
|
|
|
|
|
|
* ключ не трогаем и наших денег не тратим.
|
|
|
|
|
|
*/
|
|
|
|
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
|
config(['services.sms.sandbox' => true]);
|
|
|
|
|
|
// ДаДата включена (ключ задан) — иначе обогащение не стартует вовсе.
|
|
|
|
|
|
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
|
|
|
|
|
|
Cache::flush(); // чистый дневной бюджет ДаДаты
|
|
|
|
|
|
|
|
|
|
|
|
$this->tenant = Tenant::factory()->create();
|
|
|
|
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
|
|
|
|
$this->actingAs($this->user);
|
|
|
|
|
|
|
|
|
|
|
|
// 🪤 Заглушка ставится ОДИН раз и читает изменяемый список: повторный вызов
|
|
|
|
|
|
// Http::fake() прежнюю заглушку НЕ заменяет — отвечает по-прежнему ПЕРВАЯ, и
|
|
|
|
|
|
// тест «а теперь ДаДата отвечает» молча проверял бы старый ответ (В-106).
|
|
|
|
|
|
$this->daDataByPhone = [];
|
|
|
|
|
|
$this->daDataProvider = 'МТС';
|
|
|
|
|
|
|
|
|
|
|
|
Http::fake(['cleaner.dadata.ru/*' => function ($request) {
|
|
|
|
|
|
$phone = (string) (($request->data()[0]) ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
return Http::response([[
|
|
|
|
|
|
'qc' => 0,
|
|
|
|
|
|
'provider' => $this->daDataProvider,
|
|
|
|
|
|
'timezone' => $this->daDataByPhone[$phone] ?? null,
|
|
|
|
|
|
]], 200);
|
|
|
|
|
|
}]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Момент UTC по человеческой записи. */
|
|
|
|
|
|
function snapRegionUtc(string $iso): CarbonImmutable
|
|
|
|
|
|
{
|
|
|
|
|
|
return CarbonImmutable::parse($iso, 'UTC');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Что ДаДата отвечает про каждый номер. Можно менять по ходу теста — заглушка
|
|
|
|
|
|
* читает этот список на каждый вопрос (см. ловушку В-106 в beforeEach).
|
|
|
|
|
|
*
|
2026-08-01 19:50:41 +03:00
|
|
|
|
* @param array<int|string, ?string> $byPhone номер → строка пояса ДаДаты («UTC+3»)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Ключ int|string намеренно: PHP сам делает из ключа-номера целое число,
|
|
|
|
|
|
* поэтому обещание «только строка» было ложным.
|
2026-07-28 19:28:14 +03:00
|
|
|
|
*/
|
|
|
|
|
|
function snapRegionDaData(array $byPhone): void
|
|
|
|
|
|
{
|
|
|
|
|
|
test()->daDataByPhone = $byPhone;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Заказ рассылки по списку, вписанному руками. */
|
|
|
|
|
|
function snapRegionOrder(array $phones): int
|
|
|
|
|
|
{
|
|
|
|
|
|
return (int) test()->postJson('/api/sms/campaigns', [
|
|
|
|
|
|
'title' => 'Свой список',
|
|
|
|
|
|
'source' => 'manual',
|
|
|
|
|
|
'body' => 'Здравствуйте!',
|
|
|
|
|
|
'phones' => $phones,
|
|
|
|
|
|
])->assertCreated()->json('id');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Сделка с регионом (или без него). 🪤 Регион живёт в subject_code, НЕ в region_code (В-82). */
|
|
|
|
|
|
function snapRegionDeal(int $tenantId, string $phone, ?int $subjectCode): Deal
|
|
|
|
|
|
{
|
|
|
|
|
|
return Deal::withoutEvents(fn () => Deal::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenantId,
|
|
|
|
|
|
'phone' => $phone,
|
|
|
|
|
|
'phone_operator' => 'МТС',
|
|
|
|
|
|
'subject_code' => $subjectCode,
|
|
|
|
|
|
'received_at' => now()->subDay(),
|
|
|
|
|
|
]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги.
|
|
|
|
|
|
* 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при
|
|
|
|
|
|
* создании рассылки и читателем снимка при отправке (В-46).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function snapRegionRealRoute(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
|
|
|
|
|
|
|
|
|
|
|
|
app()->instance(SmsRouter::class, $router);
|
|
|
|
|
|
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
|
|
|
|
|
|
$router,
|
|
|
|
|
|
new OperatorNormalizer,
|
2026-07-29 15:25:37 +03:00
|
|
|
|
new AllowedSmsOperators(new OperatorNormalizer),
|
2026-07-28 19:28:14 +03:00
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function snapRegionEnrich(int $campaignId, int $tenantId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
(new EnrichClientSmsSnapshotRegionJob($campaignId, $tenantId))->handle(
|
|
|
|
|
|
app(DaDataPhoneClient::class),
|
|
|
|
|
|
app(DaDataBudgetGuard::class),
|
|
|
|
|
|
app(SmsQuietHours::class),
|
|
|
|
|
|
app(OperatorNormalizer::class),
|
2026-07-29 15:25:37 +03:00
|
|
|
|
app(AllowedSmsOperators::class),
|
2026-07-28 19:28:14 +03:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — вписанный руками номер не уходит молча: заказ ставит вопрос ДаДате', function () {
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
|
|
|
|
|
|
snapRegionDaData(['79995550001' => 'UTC+3']);
|
|
|
|
|
|
|
|
|
|
|
|
Queue::fake(); // смотрим на снимок ДО того, как что-либо отработало
|
|
|
|
|
|
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
|
|
|
|
|
|
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
|
|
|
|
|
|
|
|
|
|
|
|
// Номер в снимке есть, причины пропуска у него нет, но и пояса нет — он ЖДЁТ.
|
|
|
|
|
|
expect($row->tz_offset_minutes)->toBeNull()
|
|
|
|
|
|
->and($row->skip_reason)->toBeNull()
|
|
|
|
|
|
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
// И вопрос ДаДате поставлен — без него номер ждал бы вечно.
|
|
|
|
|
|
Queue::assertPushed(EnrichClientSmsSnapshotRegionJob::class);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — узнав регион и оператора, номер уходит в своё утро', function () {
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
|
|
|
|
|
|
snapRegionDaData([
|
|
|
|
|
|
'79995550001' => 'UTC+3', // Москва — окно открыто прямо сейчас
|
|
|
|
|
|
'79995550002' => 'UTC+12', // Камчатка — у неё вечер, подождёт до утра
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001', '79995550002']);
|
|
|
|
|
|
|
|
|
|
|
|
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
|
|
|
|
|
|
|
|
|
|
|
|
// ДаДата сказала и регион, и оператора — второго обращения за оператором нет.
|
|
|
|
|
|
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
|
|
|
|
|
|
->and($rows['79995550001']->operator)->toBe('mts')
|
|
|
|
|
|
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
|
|
|
|
|
|
->and($rows['79995550002']->send_after)->not->toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
// Москвич ушёл сразу, камчатский честно ждёт своего утра.
|
|
|
|
|
|
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
|
|
|
|
|
|
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
|
|
|
|
|
|
|
|
|
|
|
|
// Наступило камчатское утро — добор доводит рассылку до конца сам.
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 22:00'));
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('контрольный: без ответа ДаДаты номер не уходит, но и не теряется', function () {
|
|
|
|
|
|
// Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать
|
|
|
|
|
|
// «уходит всегда, независимо от того, узнали мы регион или нет» (урок В-54).
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
// ДаДата отвечает, но пояса в ответе нет — как для номера, про который она не знает.
|
|
|
|
|
|
snapRegionDaData(['79995550001' => null]);
|
|
|
|
|
|
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
|
|
|
|
|
|
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
|
|
|
|
|
|
|
|
|
|
|
|
expect($row->tz_offset_minutes)->toBeNull()
|
|
|
|
|
|
->and($row->skip_reason)->toBeNull() // не потерян и не помечен зря
|
|
|
|
|
|
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0)
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
|
|
|
|
|
|
|
|
|
|
|
|
// И добор его тоже не трогает: ждать нечего, пока регион не узнан.
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — про номер, у которого пояс уже есть, ДаДату не спрашиваем (это наши деньги)', function () {
|
|
|
|
|
|
// Решение В-105: платный вопрос задаём ТОЛЬКО там, где без ответа сообщение не
|
|
|
|
|
|
// уйдёт вовсе. Лишний вопрос по рассылке на 20 000 номеров — это тысячи рублей.
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
snapRegionDaData(['79995550002' => 'UTC+12']);
|
|
|
|
|
|
|
|
|
|
|
|
Queue::fake();
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001', '79995550002']);
|
|
|
|
|
|
|
|
|
|
|
|
// Первому пояс уже проставлен (например, прошлым прогоном обогащения).
|
|
|
|
|
|
ClientSmsCampaignPhone::where('campaign_id', $id)
|
|
|
|
|
|
->where('phone', '79995550001')
|
|
|
|
|
|
->update(['tz_offset_minutes' => 180]);
|
|
|
|
|
|
|
|
|
|
|
|
snapRegionEnrich($id, (int) $this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSentCount(1);
|
|
|
|
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->where('phone', '79995550002')
|
|
|
|
|
|
->value('tz_offset_minutes'))->toBe(720);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — не узнали за сутки: честная причина, и рассылка заканчивается', function () {
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
snapRegionDaData(['79995550001' => null]); // ДаДата про этот номер ничего не знает
|
|
|
|
|
|
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
|
|
|
|
|
|
// Сутки номер ждёт — молча, без причины: вдруг регион ещё узнается (В-89).
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-04 08:00')); // прошло 23 часа
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
|
|
|
|
|
|
|
|
|
|
|
|
// Сутки прошли — называем причину и заканчиваем рассылку.
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // прошло 25 часов
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
$message = ClientSmsMessage::where('campaign_id', $id)->firstOrFail();
|
|
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))
|
|
|
|
|
|
->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
|
|
|
|
|
|
->and($message->status)->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
|
|
|
|
|
|
->and((string) $message->cost_rub)->toBe('0.00')
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — узнанный за сутки регион побеждает: причину так и не поставили', function () {
|
|
|
|
|
|
// Парный к предыдущему (В-54): если бы «через сутки» ставилось всем подряд, этот
|
|
|
|
|
|
// прогон покраснел бы — номер узнал свой регион и обязан уйти, а не получить причину.
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
snapRegionDaData(['79995550001' => null]);
|
|
|
|
|
|
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
|
|
|
|
|
|
// Через час ДаДата уже отвечает — например, обновила справочник.
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 10:00'));
|
|
|
|
|
|
snapRegionDaData(['79995550001' => 'UTC+3']);
|
|
|
|
|
|
snapRegionEnrich($id, (int) $this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
|
|
|
|
|
|
->and(ClientSmsMessage::where('campaign_id', $id)->value('status'))
|
|
|
|
|
|
->not->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
|
|
|
|
|
|
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('3.3 — деньги: заморозка снимается, когда ждать больше нечего', function () {
|
|
|
|
|
|
// В песочнице деньги не двигаются вовсе, поэтому здесь настоящий режим: иначе
|
|
|
|
|
|
// «заморозка снялась» доказывалось бы тем, что её и не было (В-81).
|
|
|
|
|
|
config(['services.sms.sandbox' => false]);
|
|
|
|
|
|
snapRegionRealRoute();
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'cleaner.dadata.ru/*' => fn () => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => null]], 200),
|
|
|
|
|
|
'*' => Http::response([
|
|
|
|
|
|
'code' => 0,
|
|
|
|
|
|
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
|
|
|
|
|
|
], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'test');
|
|
|
|
|
|
|
|
|
|
|
|
snapRegionDeal((int) $this->tenant->id, '79995550001', 82); // Москва — уйдёт
|
|
|
|
|
|
snapRegionDeal((int) $this->tenant->id, '79995550003', null); // региона нет — подождёт
|
|
|
|
|
|
|
|
|
|
|
|
$id = (int) $this->postJson('/api/sms/campaigns', [
|
|
|
|
|
|
'title' => 'По сделкам',
|
|
|
|
|
|
'source' => 'deals',
|
|
|
|
|
|
'body' => 'Здравствуйте!',
|
|
|
|
|
|
'audience_days' => 30,
|
|
|
|
|
|
])->assertCreated()->json('id');
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->firstOrFail();
|
|
|
|
|
|
expect((string) $wallet->frozen_rub)->not->toBe('0.00'); // пока ждём — держим
|
|
|
|
|
|
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
|
|
|
|
|
|
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
|
|
$wallet->refresh();
|
|
|
|
|
|
$campaign = ClientSmsCampaign::find($id);
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
|
|
|
|
|
|
->and((string) $wallet->frozen_rub)->toBe('0.00')
|
|
|
|
|
|
// Списано ровно за отправленное, а не за смету на обоих.
|
|
|
|
|
|
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00')
|
|
|
|
|
|
->and((string) $campaign->actual_cost_rub)->toBe(
|
|
|
|
|
|
bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2)
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-29 15:25:37 +03:00
|
|
|
|
it('4.14 — оператор не из четвёрки, названный ДаДатой, выбывает из снимка', function () {
|
|
|
|
|
|
// 🔴 Вторая точка решения (строка листа 4.14, решение владельца В-149).
|
|
|
|
|
|
// Сырое имя оператора есть только в ответе ДаДаты: в снимок ложится
|
|
|
|
|
|
// канонический ключ, и «Тинькофф Мобайл» стал бы там неотличим от «ещё не
|
|
|
|
|
|
// спрашивали». Значит приговор выносится в момент ответа, другого момента нет.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Тест ПАРНЫЙ (урок В-54): рядом стоит тот же прогон с оператором из четвёрки.
|
|
|
|
|
|
// Без него «выбыл» доказывало бы разве что «выбывают все подряд».
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
|
|
|
|
|
|
snapRegionDaData(['79995550001' => 'UTC+3', '79995550002' => 'UTC+3']);
|
|
|
|
|
|
|
|
|
|
|
|
Queue::fake(); // обогащение зовём руками, чтобы смотреть ровно на него
|
|
|
|
|
|
|
|
|
|
|
|
$allowed = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
// 🪤 Текст у второй рассылки ДРУГОЙ: одинаковый текст в те же десять минут
|
|
|
|
|
|
// портал считает случайным повтором и отвечает 409 (защита из строки 2.6).
|
|
|
|
|
|
$denied = (int) $this->postJson('/api/sms/campaigns', [
|
|
|
|
|
|
'title' => 'Свой список — второй',
|
|
|
|
|
|
'source' => 'manual',
|
|
|
|
|
|
'body' => 'Добрый день!',
|
|
|
|
|
|
'phones' => ['79995550002'],
|
|
|
|
|
|
])->assertCreated()->json('id');
|
|
|
|
|
|
|
|
|
|
|
|
$this->daDataProvider = 'МТС';
|
|
|
|
|
|
snapRegionEnrich($allowed, (int) $this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
$this->daDataProvider = 'Тинькофф Мобайл';
|
|
|
|
|
|
snapRegionEnrich($denied, (int) $this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
$allowedRow = ClientSmsCampaignPhone::where('campaign_id', $allowed)->firstOrFail();
|
|
|
|
|
|
$deniedRow = ClientSmsCampaignPhone::where('campaign_id', $denied)->firstOrFail();
|
|
|
|
|
|
|
|
|
|
|
|
// Абонент четвёрки: узнал пояс и оператора, причины пропуска нет.
|
|
|
|
|
|
expect($allowedRow->skip_reason)->toBeNull()
|
|
|
|
|
|
->and($allowedRow->tz_offset_minutes)->toBe(180)
|
|
|
|
|
|
->and($allowedRow->operator)->toBe('mts');
|
|
|
|
|
|
|
|
|
|
|
|
// Мелкий оператор: честная причина, и время ему больше не нужно.
|
|
|
|
|
|
expect($deniedRow->skip_reason)->toBe(ClientSmsMessage::SKIP_OPERATOR_DENIED)
|
|
|
|
|
|
->and($deniedRow->tz_offset_minutes)->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 19:28:14 +03:00
|
|
|
|
it('3.3 — выбранный дневной лимит ДаДаты не помечает номера окончательно', function () {
|
|
|
|
|
|
// Лимит выбран — это не «регион неизвестен навсегда», а «спросим завтра».
|
|
|
|
|
|
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
|
|
|
|
|
|
snapRegionDaData(['79995550001' => 'UTC+3']);
|
|
|
|
|
|
config(['services.dadata.daily_cap_rub' => 1]);
|
|
|
|
|
|
app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽
|
|
|
|
|
|
|
|
|
|
|
|
Queue::fake();
|
|
|
|
|
|
$id = snapRegionOrder(['79995550001']);
|
|
|
|
|
|
|
|
|
|
|
|
snapRegionEnrich($id, (int) $this->tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
|
expect($row->tz_offset_minutes)->toBeNull()
|
|
|
|
|
|
->and($row->skip_reason)->toBeNull();
|
|
|
|
|
|
});
|