6e29acb7af
Строка листа 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>
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();
|
|
});
|