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>
364 lines
16 KiB
PHP
364 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Http\Controllers\Api\Client\ClientSmsBaseController;
|
|
use App\Jobs\EnrichClientSmsContactsOperatorJob;
|
|
use App\Models\ClientSmsContact;
|
|
use App\Models\ClientSmsSettings;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\ClientSms\ClientSmsUploadLimit;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\DB;
|
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;
|
|
|
|
/**
|
|
* Загрузка клиентской базы номеров Excel-файлом (POST /api/sms/contacts/file).
|
|
* Синтетические номера 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);
|
|
});
|
|
|
|
/** @param list<string> $colA */
|
|
function uploadedXlsx(array $colA): UploadedFile
|
|
{
|
|
$ss = new Spreadsheet;
|
|
$sheet = $ss->getActiveSheet();
|
|
foreach ($colA as $i => $v) {
|
|
$sheet->setCellValueExplicit('A'.($i + 1), $v, DataType::TYPE_STRING);
|
|
}
|
|
$path = tempnam(sys_get_temp_dir(), 'baza').'.xlsx';
|
|
(new XlsxWriter($ss))->save($path);
|
|
|
|
return new UploadedFile($path, 'baza.xlsx', null, null, true);
|
|
}
|
|
|
|
it('загружает базу из Excel-файла: добавляет номера, сообщает отброшенные', function () {
|
|
Bus::fake();
|
|
|
|
$file = uploadedXlsx(['Телефон', '79990000001', '+7 999 000-00-02', 'мусор']);
|
|
|
|
$res = $this->post('/api/sms/contacts/file', ['file' => $file], ['Accept' => 'application/json']);
|
|
|
|
$res->assertOk()
|
|
->assertJsonPath('added', 2) // 79990000001 + 79990000002
|
|
->assertJsonPath('rejected', 1); // «мусор»
|
|
|
|
expect($res->json('rejected_samples'))->toContain('мусор');
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(2);
|
|
|
|
Bus::assertDispatched(EnrichClientSmsContactsOperatorJob::class);
|
|
});
|
|
|
|
it('отдаёт пример файла для скачивания (Excel)', function () {
|
|
$res = $this->get('/api/sms/contacts/example');
|
|
|
|
$res->assertOk();
|
|
expect($res->headers->get('content-type'))->toContain('spreadsheetml');
|
|
expect(substr((string) $res->getContent(), 0, 2))->toBe('PK');
|
|
});
|
|
|
|
it('файл не Excel отклоняется валидацией', function () {
|
|
$bad = UploadedFile::fake()->create('spisok.txt', 1, 'text/plain');
|
|
|
|
$this->post('/api/sms/contacts/file', ['file' => $bad], ['Accept' => 'application/json'])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('file');
|
|
});
|
|
|
|
// ─── Потолок загрузки, строка листа 4.6 ─────────────────────────────────────
|
|
//
|
|
// Помощник с префиксом baza* — имена в Pest ГЛОБАЛЬНЫЕ, совпадение с соседним
|
|
// файлом роняет весь прогон.
|
|
|
|
/**
|
|
* Синтетические номера 7999… по счёту, ровно 11 знаков.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
function bazaPhones(int $count, int $from = 0): array
|
|
{
|
|
$phones = [];
|
|
for ($i = $from; $i < $from + $count; $i++) {
|
|
$phones[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
return $phones;
|
|
}
|
|
|
|
/** Ставит потолок загрузки, чтобы тесты не гоняли по 50 000 номеров зря. */
|
|
function bazaLimit(int $max): void
|
|
{
|
|
$settings = ClientSmsSettings::first() ?? new ClientSmsSettings;
|
|
$settings->fill(['max_upload_phones' => $max]);
|
|
$settings->save();
|
|
}
|
|
|
|
it('4.6 — файл сверх потолка отклоняется целиком, и сказано, сколько можно', function () {
|
|
Bus::fake();
|
|
bazaLimit(3);
|
|
|
|
$file = uploadedXlsx(array_merge(['Телефон'], bazaPhones(4)));
|
|
|
|
$res = $this->post('/api/sms/contacts/file', ['file' => $file], ['Accept' => 'application/json']);
|
|
|
|
$res->assertStatus(422)->assertJsonValidationErrors('file');
|
|
|
|
// Человек должен увидеть ОБА числа: и сколько можно, и сколько он прислал.
|
|
$message = (string) $res->json('errors.file.0');
|
|
expect($message)->toContain('3')->toContain('4');
|
|
|
|
// Частично загруженная база хуже незагруженной — не легло НИ ОДНОГО номера.
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
|
|
|
Bus::assertNotDispatched(EnrichClientSmsContactsOperatorJob::class);
|
|
});
|
|
|
|
it('4.6 — список сверх потолка отклоняется так же, как файл', function () {
|
|
Bus::fake();
|
|
bazaLimit(3);
|
|
|
|
$res = $this->postJson('/api/sms/contacts', ['phones' => bazaPhones(4)]);
|
|
|
|
$res->assertStatus(422)->assertJsonValidationErrors('phones');
|
|
|
|
$message = (string) $res->json('errors.phones.0');
|
|
expect($message)->toContain('3')->toContain('4');
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
|
});
|
|
|
|
it('4.6 — потолок спрашивается ДО построчной проверки номеров', function () {
|
|
Bus::fake();
|
|
bazaLimit(3);
|
|
|
|
// В списке не строки, а числа: построчная проверка сказала бы про них своё
|
|
// («поле должно быть строкой»). Если человек увидит ЭТО вместо потолка —
|
|
// значит проверки идут не в том порядке, а на больших списках построчная
|
|
// стоит 20 секунд и запрос умирает, не успев объяснить, сколько можно.
|
|
$res = $this->postJson('/api/sms/contacts', ['phones' => [1, 2, 3, 4]]);
|
|
|
|
$res->assertStatus(422)->assertJsonValidationErrors('phones');
|
|
expect((string) $res->json('errors.phones.0'))->toContain('За один раз');
|
|
});
|
|
|
|
it('4.6 — ровно по потолку проходит', function () {
|
|
Bus::fake();
|
|
bazaLimit(3);
|
|
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(3)])
|
|
->assertOk()
|
|
->assertJsonPath('added', 3);
|
|
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(3);
|
|
});
|
|
|
|
it('4.6 — потолок берётся из настроек, а не зашит в код', function () {
|
|
Bus::fake();
|
|
bazaLimit(10);
|
|
|
|
// При потолке 3 (прошлый тест) эти четыре номера отбились бы. При потолке 10 — проходят.
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(4)])
|
|
->assertOk()
|
|
->assertJsonPath('added', 4);
|
|
});
|
|
|
|
it('4.6 — 20 000 номеров грузятся пачками, а не по одному запросу на номер', function () {
|
|
Bus::fake();
|
|
|
|
$queries = 0;
|
|
DB::listen(function () use (&$queries): void {
|
|
$queries++;
|
|
});
|
|
|
|
$started = microtime(true);
|
|
$res = $this->postJson('/api/sms/contacts', ['phones' => bazaPhones(20000)]);
|
|
$elapsed = microtime(true) - $started;
|
|
|
|
fwrite(STDERR, sprintf("\n[4.6] загрузка 20 000 номеров: %d запросов, %.2f с\n", $queries, $elapsed));
|
|
|
|
$res->assertOk()->assertJsonPath('added', 20000);
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(20000);
|
|
|
|
// 🔑 Прибор — ЗАПРОСЫ, а не время: время на стенде плавает и порогом врало бы.
|
|
// Построчная запись (updateOrCreate на номер) даёт 20 000+ запросов, пачками — десятки.
|
|
expect($queries)->toBeLessThan(200);
|
|
});
|
|
|
|
it('4.6 — повторная загрузка тех же номеров не задваивает базу', function () {
|
|
Bus::fake();
|
|
|
|
$phones = bazaPhones(50);
|
|
|
|
$this->postJson('/api/sms/contacts', ['phones' => $phones])->assertOk();
|
|
$this->postJson('/api/sms/contacts', ['phones' => $phones])->assertOk();
|
|
|
|
// Запрет дублей стоит на паре «клиент + номер» (миграция 2026_07_27_100400).
|
|
// Пачечная запись обязана его уважать так же, как построчная.
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(50);
|
|
});
|
|
|
|
it('4.6 — одинаковые номера ВНУТРИ одной загрузки не роняют её', function () {
|
|
Bus::fake();
|
|
|
|
// Человек прислал один и тот же номер дважды в одном файле — обычное дело.
|
|
$res = $this->postJson('/api/sms/contacts', [
|
|
'phones' => ['79990000001', '+7 999 000-00-01', '79990000002'],
|
|
]);
|
|
|
|
$res->assertOk()->assertJsonPath('added', 2);
|
|
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(2);
|
|
});
|
|
|
|
it('4.6 — повторная загрузка не стирает оператора, за которого мы заплатили ДаДате', function () {
|
|
Bus::fake();
|
|
|
|
$this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk();
|
|
|
|
// ДаДата ответила — оператор и пояс легли в базу (мы за этот ответ заплатили).
|
|
ClientSmsContact::where('tenant_id', $this->tenant->id)
|
|
->where('phone', '79990000001')
|
|
->update(['operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
// Клиент загрузил тот же файл ещё раз.
|
|
$this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk();
|
|
|
|
$contact = ClientSmsContact::where('tenant_id', $this->tenant->id)->first();
|
|
expect($contact->operator)->toBe('МТС')
|
|
->and($contact->tz_offset_minutes)->toBe(180);
|
|
});
|
|
|
|
it('4.6 — экран узнаёт потолок с сервера, а не выдумывает его', function () {
|
|
bazaLimit(7000);
|
|
|
|
$this->getJson('/api/sms/contacts')
|
|
->assertOk()
|
|
->assertJsonPath('max_upload_phones', 7000);
|
|
});
|
|
|
|
// ─── Постраничный список базы, строка листа 4.7 ─────────────────────────────
|
|
//
|
|
// Своя база может быть на десятки тысяч номеров. Отдавать её экрану одним
|
|
// куском — это мегабайты в одном ответе и подвисший экран у человека.
|
|
|
|
it('4.7 — список отдаёт СТРАНИЦУ, а не всю базу, и говорит, сколько всего', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
|
|
|
|
$res = $this->getJson('/api/sms/contacts');
|
|
|
|
$res->assertOk()
|
|
->assertJsonPath('total', 150) // сколько ВСЕГО в базе — человеку это и показываем
|
|
->assertJsonPath('page', 1)
|
|
->assertJsonPath('per_page', 50)
|
|
->assertJsonPath('last_page', 3);
|
|
|
|
expect($res->json('items'))->toHaveCount(50);
|
|
|
|
// Потолок загрузки из ответа не пропал: он приехал сюда в Task 5 и экран его
|
|
// читает ДО загрузки (строка 4.6). Добавляя страницы, ломать это нельзя.
|
|
expect($res->json('max_upload_phones'))->toBe(ClientSmsUploadLimit::DEFAULT);
|
|
});
|
|
|
|
it('4.7 — вторая страница отдаёт СЛЕДУЮЩИЕ номера, а не те же самые', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
|
|
|
|
$first = collect($this->getJson('/api/sms/contacts?page=1')->json('items'))->pluck('id');
|
|
$second = collect($this->getJson('/api/sms/contacts?page=2')->json('items'))->pluck('id');
|
|
|
|
expect($second)->toHaveCount(50)
|
|
->and($second->intersect($first))->toBeEmpty(); // ни одного повтора
|
|
});
|
|
|
|
it('4.7 — последняя страница отдаёт остаток, а не пустоту', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(120)])->assertOk();
|
|
|
|
$res = $this->getJson('/api/sms/contacts?page=3');
|
|
|
|
$res->assertOk()->assertJsonPath('last_page', 3);
|
|
expect($res->json('items'))->toHaveCount(20);
|
|
});
|
|
|
|
it('4.7 — всю базу нельзя вытянуть, попросив огромную страницу', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(300)])->assertOk();
|
|
|
|
// 🔑 Обязательство «не грузится целиком» держится НЕ тем, что экран просит по 50,
|
|
// а тем, что больше отдать нельзя: иначе постраничность обходится одним
|
|
// параметром в адресе (В-166). Тот же приём, что у потолка загрузки в 4.6.
|
|
$res = $this->getJson('/api/sms/contacts?per_page=100000');
|
|
|
|
$res->assertOk()->assertJsonPath('per_page', 200);
|
|
expect($res->json('items'))->toHaveCount(200);
|
|
});
|
|
|
|
it('4.7 — размер страницы в разумных пределах слушается', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(30)])->assertOk();
|
|
|
|
$res = $this->getJson('/api/sms/contacts?per_page=10');
|
|
|
|
$res->assertOk()->assertJsonPath('per_page', 10)->assertJsonPath('last_page', 3);
|
|
expect($res->json('items'))->toHaveCount(10);
|
|
});
|
|
|
|
it('4.7 — номер страницы берётся из СВОЕГО запроса, а не из глобального', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
|
|
|
|
// 🔑 Зовём метод так же, как его зовёт живой прогон: напрямую, со своим запросом.
|
|
// Через getJson этой дыры не видно ВООБЩЕ — там глобальный запрос и есть наш,
|
|
// и шесть зелёных тестов врали хором, пока прогон не отдал вторую страницу,
|
|
// неотличимую от первой (В-167).
|
|
$request = Request::create('/api/sms/contacts?page=2', 'GET');
|
|
$request->setUserResolver(fn () => $this->user);
|
|
|
|
$body = json_decode(app(ClientSmsBaseController::class)->contacts($request)->getContent(), true);
|
|
|
|
expect($body['page'])->toBe(2);
|
|
|
|
$firstPageIds = ClientSmsContact::where('tenant_id', $this->tenant->id)
|
|
->orderByDesc('id')->limit(50)->pluck('id')->all();
|
|
expect(array_intersect(array_column($body['items'], 'id'), $firstPageIds))->toBeEmpty();
|
|
});
|
|
|
|
it('4.7 — в страницу не попадают чужие номера', function () {
|
|
Bus::fake();
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(60)])->assertOk();
|
|
|
|
$foreign = Tenant::factory()->create();
|
|
$foreignUser = User::factory()->create(['tenant_id' => $foreign->id]);
|
|
$this->actingAs($foreignUser);
|
|
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(5, 900000)])->assertOk();
|
|
|
|
// Чужой видит ТОЛЬКО свои пять — и «всего» у него тоже пять, а не 65.
|
|
$res = $this->getJson('/api/sms/contacts');
|
|
$res->assertOk()->assertJsonPath('total', 5);
|
|
expect($res->json('items'))->toHaveCount(5);
|
|
|
|
// А мы — свои шестьдесят.
|
|
$this->actingAs($this->user);
|
|
$this->getJson('/api/sms/contacts')->assertJsonPath('total', 60);
|
|
});
|
|
|
|
it('4.6 — без строки настроек потолок всё равно есть', function () {
|
|
// Строка настроек на стенде обычно есть (её заводит миграция), но код не имеет
|
|
// права на неё РАССЧИТЫВАТЬ: нет строки — потолок обязан остаться, иначе
|
|
// загрузка либо не ограничена вовсе, либо запрещена целиком.
|
|
ClientSmsSettings::query()->delete();
|
|
|
|
expect(app(ClientSmsUploadLimit::class)->max())->toBe(ClientSmsUploadLimit::DEFAULT);
|
|
});
|