tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** @param list $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 */ 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); });