b6a15c5bbf
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.
Девять столкновений, каждое разобрано по существу.
Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.
Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.
Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.
Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.
Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.
СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.
Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
- 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
перенесены из рабочей ветки, где владелец их уже принял;
- остальные 42 - свои, в новом коде ветки, и починены по существу:
задвоенный ключ массива в трёх тестах (след копирования - комментарий
оторвался от своей строки), врущие описания двух помощников (PHP сам
делает из ключа-номера число), сужение типа возврата, прятавшее от
анализатора свойства подставного отправителя, лишний знак вопроса и
четыре бесполезных перенумерования списка.
- Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
поимённо и покраснел; убрал - ноль.
Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.
Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.
Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.
Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.
NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
9.3 KiB
PHP
239 lines
9.3 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\SmsOutgoing;
|
||
use App\Services\Sms\SmsProvider;
|
||
use App\Services\Sms\SmsRouter;
|
||
use App\Services\Sms\SmsSendResult;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
/**
|
||
* Строки листа 1.14–1.17: кнопка «Остановить».
|
||
*
|
||
* Здесь важнее всего не «остановилось», а «остановилось ЧЕСТНО»: ни одного нового
|
||
* СМС после нажатия, начатое доведено, итог и деньги сходятся с журналом, заморозка
|
||
* снята. Поэтому проверяется не статус кампании, а число обращений к провайдеру и
|
||
* кошелёк.
|
||
*
|
||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
|
||
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем —
|
||
// иначе тест зависел бы от часа, в который его запустили (вечером все номера
|
||
// ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто.
|
||
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
|
||
});
|
||
|
||
/**
|
||
* Провайдер-счётчик: на N-й отправке дёргает переданный обработчик.
|
||
*
|
||
* Тип возврата НЕ сужен до SmsProvider намеренно: тест читает счётчик `$calls`,
|
||
* а интерфейс его не обещает — под интерфейсным типом анализатор счётчика не видит
|
||
* и краснеет, хотя свойство у класса есть.
|
||
*/
|
||
function cancelSpyProvider(?Closure $onSend = null)
|
||
{
|
||
return new class($onSend) implements SmsProvider
|
||
{
|
||
public int $calls = 0;
|
||
|
||
public function __construct(private readonly ?Closure $onSend) {}
|
||
|
||
public function key(): string
|
||
{
|
||
return 'mts';
|
||
}
|
||
|
||
public function servesOperators(): array
|
||
{
|
||
return ['mts'];
|
||
}
|
||
|
||
public function priceKopecks(string $operator): int
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
public function send(SmsOutgoing $message): SmsSendResult
|
||
{
|
||
$this->calls++;
|
||
|
||
if ($this->onSend !== null) {
|
||
($this->onSend)($this->calls);
|
||
}
|
||
|
||
return new SmsSendResult('MSG-CANCEL-1', $message->segments, 0, CarbonImmutable::now());
|
||
}
|
||
};
|
||
}
|
||
|
||
function cancelCampaign(int $tenantId, int $count): 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' => $count,
|
||
'total_sms' => $count,
|
||
'price_rub_per_sms' => '8.50',
|
||
'estimated_cost_rub' => bcmul('8.50', (string) $count, 2),
|
||
'created_by' => null,
|
||
]);
|
||
}
|
||
|
||
function cancelContacts(int $tenantId, int $count): void
|
||
{
|
||
foreach (range(1, $count) as $i) {
|
||
ClientSmsContact::create([
|
||
'tenant_id' => $tenantId,
|
||
'phone' => '7999000010'.$i,
|
||
'name' => 'Контакт '.$i,
|
||
'operator' => 'МТС',
|
||
// Этап 3: без известного региона номер не уходит вовсе (В-85).
|
||
'tz_offset_minutes' => 180,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Снимок получателей + канал, которым пойдут сообщения — то, что в бою делает
|
||
* контроллер при создании рассылки (строки листа 2.1–2.2).
|
||
*
|
||
* 🪤 Роутер ставится В КОНТЕЙНЕР: канал выбирается дважды — отборщиком при снимке
|
||
* и читателем снимка при отправке. Подменить один отборщик теперь мало.
|
||
*/
|
||
function cancelSnapshot(ClientSmsCampaign $campaign, SmsProvider $provider): void
|
||
{
|
||
$router = new SmsRouter([$provider]);
|
||
app()->instance(SmsRouter::class, $router);
|
||
|
||
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
|
||
$plan = (new ClientSmsRecipientSelector($router, new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer)))
|
||
->build($recipients, (int) $campaign->tenant_id);
|
||
|
||
app(ClientSmsSnapshotWriter::class)->write((int) $campaign->tenant_id, (int) $campaign->id, $plan);
|
||
}
|
||
|
||
it('после остановки провайдер больше не вызывается ни разу', function () {
|
||
config(['services.sms.sandbox' => true]);
|
||
|
||
cancelContacts($this->tenant->id, 5);
|
||
$campaign = cancelCampaign($this->tenant->id, 5);
|
||
|
||
// Клиент нажимает «Остановить» ровно в тот момент, когда ушло второе сообщение.
|
||
$spy = cancelSpyProvider(function (int $n) use ($campaign) {
|
||
if ($n === 2) {
|
||
$campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]);
|
||
}
|
||
});
|
||
|
||
cancelSnapshot($campaign, $spy);
|
||
|
||
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
|
||
app(ClientSmsSnapshotReader::class),
|
||
app(ClientSmsPricing::class),
|
||
app(AdWalletService::class),
|
||
);
|
||
|
||
expect($spy->calls)->toBe(2);
|
||
});
|
||
|
||
it('итог остановленной рассылки честный и деньги сходятся', function () {
|
||
config(['services.sms.sandbox' => false]);
|
||
|
||
$wallet = app(AdWalletService::class);
|
||
$wallet->topup($this->tenant->id, '1000.00', null, 'test');
|
||
|
||
cancelContacts($this->tenant->id, 5);
|
||
$campaign = cancelCampaign($this->tenant->id, 5);
|
||
$wallet->freeze($this->tenant->id, 'sms', 'campaign', $campaign->id, '42.50');
|
||
|
||
$spy = cancelSpyProvider(function (int $n) use ($campaign) {
|
||
if ($n === 2) {
|
||
$campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]);
|
||
}
|
||
});
|
||
|
||
cancelSnapshot($campaign, $spy);
|
||
|
||
(new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle(
|
||
app(ClientSmsSnapshotReader::class),
|
||
app(ClientSmsPricing::class),
|
||
$wallet,
|
||
);
|
||
|
||
$campaign->refresh();
|
||
|
||
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
|
||
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_CLIENT)
|
||
->and($campaign->sent_count)->toBe(2)
|
||
->and((string) $campaign->actual_cost_rub)->toBe('17.00'); // 2 × 8.50, не 42.50
|
||
|
||
// Номера, до которых не дошли, в журнале отсутствуют (В-27).
|
||
expect(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
|
||
|
||
$after = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $after->balance_rub)->toBe('983.00') // 1000 − 17
|
||
->and((string) $after->frozen_rub)->toBe('0.00'); // заморозка снята полностью
|
||
});
|
||
|
||
it('остановить можно в очереди, в отправке и в ожидании окна', function () {
|
||
foreach ([
|
||
ClientSmsCampaign::STATUS_QUEUED,
|
||
ClientSmsCampaign::STATUS_SENDING,
|
||
ClientSmsCampaign::STATUS_WAITING_WINDOW,
|
||
] as $status) {
|
||
$campaign = cancelCampaign($this->tenant->id, 1);
|
||
$campaign->update(['status' => $status]);
|
||
|
||
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertOk();
|
||
|
||
expect($campaign->fresh()->cancel_requested_at)->not->toBeNull();
|
||
}
|
||
});
|
||
|
||
it('законченную рассылку остановить нельзя', function () {
|
||
$campaign = cancelCampaign($this->tenant->id, 1);
|
||
$campaign->update(['status' => ClientSmsCampaign::STATUS_DONE]);
|
||
|
||
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertStatus(409);
|
||
|
||
expect($campaign->fresh()->cancel_requested_at)->toBeNull();
|
||
});
|
||
|
||
it('чужую рассылку остановить нельзя', function () {
|
||
$stranger = Tenant::factory()->create();
|
||
$campaign = cancelCampaign($stranger->id, 1);
|
||
|
||
$this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertNotFound();
|
||
|
||
expect($campaign->fresh()->cancel_requested_at)->toBeNull();
|
||
});
|