Перейти к содержанию

Проверка отправки файла

В предыдущем уроке каталог отдавал файл читателю. Теперь рассмотрим обратное действие: читатель выбирает JSON со списком курсов, а страница показывает, какие названия в нём указаны. Проверка должна связать выбранные байты, ответ сервера, результат интерфейса и отказ для неправильного ввода.

Мы добавим только предпросмотр. Исходники lesson-19/start и expected находятся в архиве продолжения. Каталог из четырёх курсов и пятидесяти уроков не изменяется; выбранные файлы не сохраняются. Тесты, отправка запросов и работа учебного сервера при подготовке не выполнялись.

Небольшой формат выбора

Файл содержит объект с массивом courseIds. В нём допустимо от одного до четырёх разных известных идентификаторов. Для примера выберем курс HTML и курс Markdown:

{"courseIds":["c001","c004"]}

Такой формат не является полной выгрузкой предыдущего урока. Выгрузка описывает записи, а файл выбора только ссылается на известные серверу идентификаторы. Эта разница должна быть видна в форме и статье: попытка отправить catalog.json как выбор получит отказ, поскольку поля courseIds в нём нет.

Форма содержит подписанный input type="file", подсказку о JSON и пределе 4096 байт, кнопку «Показать файл», область ошибки и список предпросмотра. accept помогает обычному выбору файла, но не является доказательством его формата. Пользовательские файлы и запросы можно сформировать иначе, поэтому сервер обязан независимо проверить содержимое.

В лаборатории не реализуется multipart/form-data. Браузер читает выбранный файл через file.text() и отправляет его текст с Content-Type: application/json на /api/import-preview. Это намеренное ограничение небольшого примера. Оно позволяет исследовать выбор файла и договорённость данных без сохранения бинарных вложений и без установки дополнительного парсера.

Выбор байтов через Playwright

В сценарии не нужен настоящий документ на компьютере. Создадим файл из памяти и установим его в поле с помощью setInputFiles():

await page.getByLabel('Файл выбора курсов', { exact: true })
  .setInputFiles({
    name: 'selection.json',
    mimeType: 'application/json',
    buffer: Buffer.from(JSON.stringify({ courseIds: ['c001', 'c004'] }))
  });

Имя, тип и буфер задают разные свойства. MIME-тип не подтверждает, что внутри корректный JSON, а расширение не подтверждает допустимые идентификаторы. Такое разделение позволяет менять одно условие в следующем варианте. Например, тот же корректный буфер под именем selection.txt будет отклонён нашей клиентской проверкой имени.

Порядок выбора и варианты файлов из памяти приведены в документации действий Playwright. Для нашего сценария выбранное поле существует сразу, поэтому отдельное ожидание filechooser не требуется. Этот метод подготавливает браузерное поле, но не нажимает кнопку отправки и не подтверждает ответ API.

Отправка и результат предпросмотра

Ожидание ответа создаётся перед нажатием кнопки. В полном сценарии оно ограничено нужным pathname и методом POST. После ответа проверяется статус, затем автоматическими ожиданиями проверяется отображённый результат.

const response = page.waitForResponse(response =>
  new URL(response.url()).pathname === '/api/import-preview' &&
  response.request().method() === 'POST');
await page.getByRole('button', { name: 'Показать файл', exact: true }).click();
expect((await response).status()).toBe(200);
await expect(page.getByTestId('import-status'))
  .toHaveText('В файле курсов: 2');
await expect(page.getByRole('list', { name: 'Предпросмотр файла' })
  .getByRole('listitem'))
  .toHaveText(['Основы HTML', 'Статический сайт из Markdown']);

В конце проверяем, что основной список всё ещё содержит четыре курса. Предпросмотр располагается в отдельной области и не вызывает общий фильтр. Порядок названий следует порядку ID в файле. Таким образом, сценарий наблюдает конкретный результат выбранных данных, а не любое появление двух элементов на странице.

Ответ сервера формируется по собственному массиву courses. Название из пользовательского JSON не выводится, поскольку такое поле договорённость вообще не использует. В интерфейсе элементы создаются через textContent, а не вставку HTML из файла. Это упрощает пример, однако не превращает его в универсальную безопасную обработку файлов любого типа.

Два разных отказа

Первый отрицательный сценарий передаёт корректные данные под именем selection.txt. Клиент должен показать сообщение о JSON и размере, вернуть фокус полю и оставить предпросмотр пустым. Проверка имени выполняется до чтения и запроса. Это полезная помощь пользователю, но не защита API: прямой запрос не проходит через эту форму.

Поэтому второй сценарий обращается непосредственно к API и отправляет неизвестный c999. Ожидается статус 400 и invalid-selection. Сервер также проверяет массив, число элементов, отсутствие повторов и принадлежность каждого строкового ID известному каталогу. Если браузерная проверка исчезнет, эти ограничения останутся на стороне приёма данных.

При невалидном JSON или превышении лимита тела общий обработчик лаборатории возвращает 400 с invalid-request. Неподдерживаемый Content-Type получает 415. Мы не обещаем отдельный 413 и точную классификацию всех ошибок: текущие исходники задают именно такую ограниченную модель. Для рабочего импорта её понадобится уточнить вместе с понятными пользователю сообщениями.

Если отправить {"courseIds":["c001","c001"]}, сервер отвергнет повтор, хотя каждый идентификатор известен. Это ограничение делает число выбранных курсов однозначным. Не нужно ожидать, что клиент автоматически удалит дубликаты: текущий интерфейс передаёт текст файла, а правило принадлежит API. Для удобного редактора выбора можно было бы добавить нормализацию, но это уже другое поведение и другой сценарий.

Пустой массив тоже не означает успешный предпросмотр нуля. По нашей договорённости файл должен выбирать хотя бы один курс. Поэтому он отклоняется, тогда как пустой результат поиска в каталоге раньше был нормальным состоянием. Одинаковое число ноль в двух областях не определяет одинаковую реакцию: значение задаётся конкретной задачей читателя.

Размер и отсутствие хранения

Предел 4096 считается в байтах. Поле file.size относится к файлу, а серверный предел проверяется при чтении тела. Кириллические символы занимают несколько байт в UTF-8, поэтому количество букв не равно размеру файла. Для массива коротких ID этого достаточно; большой произвольный документ относится к другой задаче.

Сервер не использует имя файла как путь, не записывает тело на диск и не изменяет массив курсов. Ответ существует только как результат текущего запроса. Это делает сценарии независимыми при двух workers: один предпросмотр не добавляет записи, которые увидит другой тест. Старый учебный lab-login также не превращает эту форму в административный импорт.

Настоящая загрузка вложений потребовала бы другой договорённости: допустимые типы и объём, права пользователя, хранение, проверка содержимого, удаление и выдача файла. Наш сценарий учит сопоставлять действие с результатом небольшого JSON-предпросмотра. Он не заявляет, что эти дополнительные свойства уже реализованы или проверены.

В качестве варианта для самостоятельного чтения поменяйте ID местами и проследите ожидаемый порядок названий. Затем рассмотрите повтор одного ID: сервер должен отклонить такой список. Выполнение этих вариантов остаётся отдельным этапом; сейчас подготовлены исходники и объяснение наблюдений, по которым можно оценить результат.

При чтении серверного исходника обратите внимание на границу UTF-8. HTTP может передать один символ несколькими порциями байтов. Поэтому сервер считает chunk.length, накапливает не больше 4096 байт и декодирует объединённый Buffer через TextDecoder с fatal: true. Он не превращает каждую порцию отдельно в строку. После превышения предела остальные порции дочитываются без накопления; для полностью переданного тела ожидается общий отказ invalid-request. Если клиент оборвал соединение, получение JSON-ответа не гарантируется. Это ограничение небольшого обработчика, а не выполненная транспортная проверка. API декодирования описан в документации Node 24.