177 lines
8.7 KiB
PHP
177 lines
8.7 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use App\Models\ClientSmsCampaign;
|
||
|
|
use App\Models\ClientSmsCampaignPhone;
|
||
|
|
use App\Models\ClientSmsMessage;
|
||
|
|
use App\Models\Tenant;
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
use Illuminate\Support\Carbon;
|
||
|
|
use Tests\Concerns\SharesSupplierPdo;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Строка листа 4.12: снимок получателей живёт 90 дней (решение владельца В-45).
|
||
|
|
*
|
||
|
|
* Снимок — копия ПЕРСОНАЛЬНЫХ данных: список номеров, кому рассылка уйдёт и кому не
|
||
|
|
* уйдёт. Держать его вечно нельзя. Обязательство строки — из ДВУХ половин, и тесты
|
||
|
|
* стоят на обе (урок В-159, читать строку вслух):
|
||
|
|
* — старше 90 дней чистится САМ, без человека;
|
||
|
|
* — рассылка и её итоги ОСТАЮТСЯ: чистится только снимок.
|
||
|
|
*
|
||
|
|
* 🪤 SharesSupplierPdo обязателен: чистка перечисляет и удаляет служебным соединением
|
||
|
|
* (она кросс-клиентская и пометку клиента не ставит). Без общего PDO это соединение не
|
||
|
|
* видит незакоммиченных данных теста и находит НОЛЬ — прогон позеленел бы ВРУЩИ там,
|
||
|
|
* где мы ждём удаления, и врал бы в обе стороны (урок В-95).
|
||
|
|
*
|
||
|
|
* Помощники с префиксом purge* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
||
|
|
* Телефоны только синтетические 7999… — реальные НИКОГДА.
|
||
|
|
*/
|
||
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
||
|
|
|
||
|
|
beforeEach(function () {
|
||
|
|
Carbon::setTestNow('2026-08-15 03:30:00');
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(function () {
|
||
|
|
Carbon::setTestNow();
|
||
|
|
});
|
||
|
|
|
||
|
|
/** Рассылка, к которой привязан снимок. Фабрики у этой модели нет — заводим как соседи. */
|
||
|
|
function purgeCampaign(int $tenantId, string $status = ClientSmsCampaign::STATUS_DONE): ClientSmsCampaign
|
||
|
|
{
|
||
|
|
return ClientSmsCampaign::create([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'title' => 'Проверка чистки снимка',
|
||
|
|
'body' => 'Текст',
|
||
|
|
'sender_name' => 'LIDERRA',
|
||
|
|
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
||
|
|
'status' => $status,
|
||
|
|
'idempotency_key' => 'purge-'.uniqid(),
|
||
|
|
'segments' => 1,
|
||
|
|
'planned_count' => 1,
|
||
|
|
'sent_count' => 1,
|
||
|
|
'total_sms' => 1,
|
||
|
|
'price_rub_per_sms' => '8.50',
|
||
|
|
'estimated_cost_rub' => '8.50',
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Строка снимка с заданным возрастом. Дату правим запросом — модель ставит своё время. */
|
||
|
|
function purgePhone(int $tenantId, int $campaignId, string $phone, string $createdAt): ClientSmsCampaignPhone
|
||
|
|
{
|
||
|
|
$row = ClientSmsCampaignPhone::create([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'campaign_id' => $campaignId,
|
||
|
|
'phone' => $phone,
|
||
|
|
]);
|
||
|
|
|
||
|
|
ClientSmsCampaignPhone::whereKey($row->id)->update(['created_at' => $createdAt]);
|
||
|
|
|
||
|
|
return $row;
|
||
|
|
}
|
||
|
|
|
||
|
|
it('4.12 — снимок старше 90 дней уходит, а свежий, рассылка и её итоги остаются', function () {
|
||
|
|
$tenant = Tenant::factory()->create();
|
||
|
|
$campaign = purgeCampaign($tenant->id);
|
||
|
|
|
||
|
|
// Граница — 90 дней от 15.08, то есть 17.05. Слева от неё чистим, справа нет.
|
||
|
|
$old = purgePhone($tenant->id, $campaign->id, '79990000001', '2026-05-16 03:00:00');
|
||
|
|
$fresh = purgePhone($tenant->id, $campaign->id, '79990000002', '2026-05-18 03:00:00');
|
||
|
|
|
||
|
|
// Итог рассылки: журнал сообщений — то, что владелец велел ОСТАВИТЬ.
|
||
|
|
$message = ClientSmsMessage::create([
|
||
|
|
'tenant_id' => $tenant->id,
|
||
|
|
'campaign_id' => $campaign->id,
|
||
|
|
'phone' => '79990000001',
|
||
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
||
|
|
'cost_rub' => '8.50',
|
||
|
|
'segments' => 1,
|
||
|
|
]);
|
||
|
|
ClientSmsMessage::whereKey($message->id)->update(['created_at' => '2026-05-16 03:05:00']);
|
||
|
|
|
||
|
|
$this->artisan('client-sms:purge-snapshots')->assertSuccessful();
|
||
|
|
|
||
|
|
expect(ClientSmsCampaignPhone::whereKey($old->id)->exists())->toBeFalse()
|
||
|
|
->and(ClientSmsCampaignPhone::whereKey($fresh->id)->exists())->toBeTrue()
|
||
|
|
// Вторая половина обязательства: рассылка и её итоги на месте.
|
||
|
|
->and(ClientSmsCampaign::whereKey($campaign->id)->exists())->toBeTrue()
|
||
|
|
->and(ClientSmsMessage::whereKey($message->id)->exists())->toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('4.12 — чистит у ВСЕХ клиентов, а не только у одного', function () {
|
||
|
|
$first = Tenant::factory()->create();
|
||
|
|
$second = Tenant::factory()->create();
|
||
|
|
|
||
|
|
$firstRow = purgePhone($first->id, purgeCampaign($first->id)->id, '79990000011', '2026-01-01 03:00:00');
|
||
|
|
$secondRow = purgePhone($second->id, purgeCampaign($second->id)->id, '79990000012', '2026-01-01 03:00:00');
|
||
|
|
|
||
|
|
$this->artisan('client-sms:purge-snapshots')->assertSuccessful();
|
||
|
|
|
||
|
|
// Команда ходит без пометки клиента — если она её где-то поставит, второй клиент уцелеет
|
||
|
|
// и обязательство будет выполнено только для первого (класс В-114, но наоборот).
|
||
|
|
expect(ClientSmsCampaignPhone::whereKey($firstRow->id)->exists())->toBeFalse()
|
||
|
|
->and(ClientSmsCampaignPhone::whereKey($secondRow->id)->exists())->toBeFalse();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('4.12 — уносит больше одной пачки за заход: 5001 строка снимка чистится целиком', function () {
|
||
|
|
$tenant = Tenant::factory()->create();
|
||
|
|
$campaign = purgeCampaign($tenant->id);
|
||
|
|
|
||
|
|
// Пачка — 5000 строк. Без цикла команда унесла бы ровно 5000 и отрапортовала успехом:
|
||
|
|
// одна строка персональных данных осталась бы жить вечно (класс В-121, «успех с остатком»).
|
||
|
|
$rows = [];
|
||
|
|
for ($i = 0; $i < 5001; $i++) {
|
||
|
|
$rows[] = [
|
||
|
|
'tenant_id' => $tenant->id,
|
||
|
|
'campaign_id' => $campaign->id,
|
||
|
|
'phone' => '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT),
|
||
|
|
'created_at' => '2026-01-01 03:00:00',
|
||
|
|
'updated_at' => '2026-01-01 03:00:00',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
foreach (array_chunk($rows, 1000) as $chunk) {
|
||
|
|
ClientSmsCampaignPhone::insert($chunk);
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->artisan('client-sms:purge-snapshots')->assertSuccessful();
|
||
|
|
|
||
|
|
expect(ClientSmsCampaignPhone::where('campaign_id', $campaign->id)->count())->toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('4.12 — срок можно задать при ручном запуске, он не зашит в запрос намертво', function () {
|
||
|
|
$tenant = Tenant::factory()->create();
|
||
|
|
$campaign = purgeCampaign($tenant->id);
|
||
|
|
|
||
|
|
$row = purgePhone($tenant->id, $campaign->id, '79990000021', '2026-07-01 03:00:00');
|
||
|
|
|
||
|
|
// 45 дней от 15.08 — внутри девяноста, при обычном запуске строка осталась бы.
|
||
|
|
$this->artisan('client-sms:purge-snapshots', ['--days' => 10])->assertSuccessful();
|
||
|
|
|
||
|
|
expect(ClientSmsCampaignPhone::whereKey($row->id)->exists())->toBeFalse();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('4.12 — нулевой срок отклоняется: он снёс бы снимки рассылок, которые идут сейчас', function () {
|
||
|
|
$tenant = Tenant::factory()->create();
|
||
|
|
$campaign = purgeCampaign($tenant->id);
|
||
|
|
|
||
|
|
$today = purgePhone($tenant->id, $campaign->id, '79990000041', '2026-08-15 03:00:00');
|
||
|
|
|
||
|
|
$this->artisan('client-sms:purge-snapshots', ['--days' => 0])->assertFailed();
|
||
|
|
|
||
|
|
expect(ClientSmsCampaignPhone::whereKey($today->id)->exists())->toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('4.12 — про снимок НЕзакончившейся рассылки говорит вслух, а не молчит (В-176)', function () {
|
||
|
|
$tenant = Tenant::factory()->create();
|
||
|
|
$live = purgeCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING);
|
||
|
|
|
||
|
|
purgePhone($tenant->id, $live->id, '79990000031', '2026-01-01 03:00:00');
|
||
|
|
|
||
|
|
// Рассылки старше 90 дней в состоянии «идёт» в норме не бывает: зависшую срывает
|
||
|
|
// сторож через час. Значит это странность, и человек обязан её увидеть.
|
||
|
|
$this->artisan('client-sms:purge-snapshots')
|
||
|
|
->expectsOutputToContain('не закончены')
|
||
|
|
->assertSuccessful();
|
||
|
|
});
|