b3a86e69ad
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты): - #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию — только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js. - #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные формы вычищаются), сравнение по голому 7XXXXXXXXXX. - #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days max:365; store возвращает dropped_count (сколько номеров не распозналось). - #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate — два воркера сериализуются на блокировке строки (защита от гонки). - #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог — config client_tg.auto_batch_threshold (367). В песочнице гейта нет. TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера). Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0. Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами, чтобы дойти до проверки денег после нового гейта). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
4.8 KiB
PHP
89 lines
4.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Queue;
|
||
|
||
/**
|
||
* Верхние пределы на вход в CampaignController::store (находка #13, задача 1.3).
|
||
* `phones`/`audience_days` без верхнего предела → клиент может прислать миллион
|
||
* номеров (нагрузка/DoS). `ad_text`/`ad_link` НЕ трогаем — уже ограничены.
|
||
*
|
||
* dropped_count — число нераспознанных (мусорных) номеров, отброшенных
|
||
* storePhones молча; добавочный ключ в ответе store, не ломает плоский контракт.
|
||
*
|
||
* Auth/tenant — как в CampaignApiTest. Синтетические номера 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);
|
||
});
|
||
|
||
/** @return array<string, mixed> валидный payload создания кампании по списку номеров. */
|
||
function limStorePayload(array $overrides = []): array
|
||
{
|
||
return array_merge([
|
||
'ad_text' => 'Приходите к нам в канал',
|
||
'ad_link' => 'https://t.me/example_channel',
|
||
'audience_kind' => 'list',
|
||
'budget_cap_rub' => '1000.00',
|
||
'phones' => ['79990000001', '79990000002'],
|
||
], $overrides);
|
||
}
|
||
|
||
// 🪤 Полный HTTP-прогон с 200001 реальным элементом здесь НЕ используется:
|
||
// замерено — 'phones' => 'array|max:200000' САМ ПО СЕБЕ отбрасывает 200001
|
||
// элементов за ~0.1с (Validator::fails() проверялся изолированно), но
|
||
// соседнее правило 'phones.*' => 'string|max:32' — это Laravel wildcard-rule,
|
||
// который валидирует КАЖДЫЙ элемент независимо от того, что 'phones' уже не
|
||
// прошёл (bail/stopOnFirstFailure на это не влияют — разные top-level поля).
|
||
// Замер роста: n=1000→0.09с, n=5000→0.37с, n=20000→2.8с (не линейно, ~n^1.8) —
|
||
// на n=200001 это уже единицы минут за ОДИН прогон теста, что не пригодно для
|
||
// регулярного TDD-цикла. Поэтому граница 200000 проверяется СТРУКТУРНО (текст
|
||
// правила в контроллере), а не полным раннтайм-прогоном 200001 элементов.
|
||
it('верхний предел phones задан в контроллере как max:200000 (структурная проверка — полный HTTP-прогон 200001 элемента через Laravel Validator занимает единицы минут из-за wildcard-правила phones.*, см. комментарий выше)', function () {
|
||
$source = file_get_contents(app_path('Http/Controllers/Api/ClientTg/CampaignController.php'));
|
||
|
||
expect($source)->toContain("'phones' => 'nullable|array|max:200000',");
|
||
});
|
||
|
||
it('phones немного за пределами разумного (500 валидных номеров) — 201, не ложное срабатывание', function () {
|
||
Queue::fake();
|
||
|
||
$this->postJson('/api/telegram/campaigns', limStorePayload([
|
||
'phones' => array_fill(0, 500, '79990000001'),
|
||
]))->assertCreated();
|
||
});
|
||
|
||
it('phones.* элемент длиннее 32 символов отклоняется — 422', function () {
|
||
$this->postJson('/api/telegram/campaigns', limStorePayload([
|
||
'phones' => [str_repeat('7', 40)],
|
||
]))->assertStatus(422)->assertJsonValidationErrors('phones.0');
|
||
});
|
||
|
||
it('audience_days=366 при audience_kind=deals отклоняется — 422', function () {
|
||
$this->postJson('/api/telegram/campaigns', limStorePayload([
|
||
'audience_kind' => 'deals',
|
||
'audience_days' => 366,
|
||
'phones' => null,
|
||
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
|
||
});
|
||
|
||
it('валидный запрос (2 номера) проходит — 201 и dropped_count=0', function () {
|
||
$this->postJson('/api/telegram/campaigns', limStorePayload())
|
||
->assertCreated()
|
||
->assertJsonPath('dropped_count', 0);
|
||
});
|
||
|
||
it('валидный запрос с 1 мусорным номером — 201 и dropped_count=1', function () {
|
||
$this->postJson('/api/telegram/campaigns', limStorePayload([
|
||
'phones' => ['79990000001', 'абв'],
|
||
]))->assertCreated()->assertJsonPath('dropped_count', 1);
|
||
});
|