Files
portal/app/tests/Feature/ClientSms/PurgeSnapshotsTest.php
T
Дмитрий 6e29acb7af feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия
ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому
нет. Делается один раз при создании рассылки и больше не пересматривается
(В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя.

Что сделано:
· команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30
  (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит
  служебным соединением — она кросс-клиентская и пометку клиента не ставит;
· пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это
  долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана
  уйти целиком, иначе одна строка ПДн осталась бы жить вечно;
· рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал
  сообщений остаются, как велел владелец. Цена этого названа вслух (В-178):
  через 90 дней уже нельзя ответить, кто из получателей ждал своего утра;
· срок живёт в коде, в админке не правится (В-177): срок зависания рассылки —
  рабочая настройка, а 90 дней — обязательство про персональные данные, одно
  для всего портала. При ручном запуске срок передать можно, нулевой
  отклоняется человеческими словами — он снёс бы снимки живых рассылок;
· про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал
  сервера (В-176): в норме такого не бывает, и молчать об этом нельзя.

Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21
(В-152). Номер и версию взял по каталогу: названные планом были заняты
Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest`
расширен и спрашивает саму базу, а не текст миграции.

🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152
(В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому
бой воспроизведён: политика поставлена тем же текстом, что в
db/03_service_bypass_policies.sql, и после прогона убрана. Тройка:
(1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки
целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни
падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом
УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая
строка на месте, рассылка и её сообщение на месте, предупреждение о
незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА
(падение), без политики НЕ видна (успешный ноль).

Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов
17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest
и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый
покраснел ровно там, где вырезан; девятый — подмена служебного соединения на
обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем
ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со
снимком ДО; намеренное изменение одно — миграция накатана на dev-базу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:47:02 +03:00

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();
});