// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с пофайловым транзитом. // Запуск: node --test tests/test_upload_layer2.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; // Полифилл File/Blob при необходимости if (typeof globalThis.File === 'undefined') { globalThis.File = class File extends Blob { constructor(parts, name, opts) { super(parts, opts); this.name = name; this.lastModified = (opts && opts.lastModified) || Date.now(); } }; } import { putToVm } from '../upload/frontend/upload/put_to_vm.js'; import { uploadViaVM } from '../upload/frontend/upload/upload_via_vm.js'; const tick = () => new Promise(r => setTimeout(r, 0)); // Мок XMLHttpRequest let lastXHR = null; class FakeXHR { constructor() { this.upload = {}; this.status = 0; this.timeout = 0; this.onload = null; this.onerror = null; this.ontimeout = null; this.onabort = null; this.method = null; this.url = null; this.body = null; this.aborted = false; lastXHR = this; } open(method, url) { this.method = method; this.url = url; } send(body) { this.body = body; } abort() { this.aborted = true; if (this.onabort) this.onabort(); } } globalThis.XMLHttpRequest = FakeXHR; // Мок fetch let fetchCalls = []; function installFetch(handler) { fetchCalls = []; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return handler(url, opts); }; } test('putToVm: успешная отправка', async () => { const f = new File(['test-content'], 'doc.pdf'); const p = putToVm(f, 'https://vm-buffer/token_0'); lastXHR.status = 200; lastXHR.onload(); await p; assert.equal(lastXHR.method, 'PUT'); assert.equal(lastXHR.url, 'https://vm-buffer/token_0'); assert.equal(lastXHR.body, f); }); test('putToVm: ошибка HTTP статуса', async () => { const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); lastXHR.status = 502; lastXHR.statusText = 'Bad Gateway'; lastXHR.onload(); await assert.rejects(p, /HTTP 502/); }); test('putToVm: сетевая ошибка', async () => { const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); lastXHR.onerror(); await assert.rejects(p, /Сетевая ошибка/); }); test('putToVm: таймаут', async () => { const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); lastXHR.ontimeout(); await assert.rejects(p, /Таймаут/); }); test('putToVm: прерывание через AbortSignal', async () => { const ac = new AbortController(); const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0', { signal: ac.signal }); ac.abort(); await assert.rejects(p, (err) => err.name === 'AbortError'); assert.equal(lastXHR.aborted, true); }); test('uploadViaVM: пофайловый транзит (N файлов -> N PUT + N upload_refs)', async () => { let callCount = 0; installFetch(async (url, opts) => { callCount++; const body = JSON.parse(opts.body); return { ok: true, json: async () => ({ ok: true, session: body.session || 'created-sid-1', count: callCount, }), }; }); const files = [ new File(['hello'], 'first.txt'), new File(['world-data'], 'second.pdf'), ]; const statuses = []; const completed = []; const p = uploadViaVM(files, { vmUploadUrl: 'https://vm-buffer/upload/', backendUploadUrl: '/api/upload_refs', onFileStatus: (idx, status) => statuses.push({ idx, status }), onFileComplete: (info) => completed.push(info), }); // Файл 1: завершаем PUT await tick(); assert.equal(lastXHR.method, 'PUT'); assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/')); lastXHR.status = 201; lastXHR.onload(); // Файл 2: завершаем PUT await tick(); assert.equal(lastXHR.method, 'PUT'); assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/')); lastXHR.status = 201; lastXHR.onload(); const res = await p; assert.equal(res.ok, true); assert.equal(res.session, 'created-sid-1'); assert.equal(res.count, 2); // Проверяем, что было РОВНО 2 вызова fetch (/api/upload_refs), по одному на каждый файл assert.equal(fetchCalls.length, 2); const call1Body = JSON.parse(fetchCalls[0].opts.body); assert.equal(call1Body.files.length, 1); assert.equal(call1Body.files[0].name, 'first.txt'); const call2Body = JSON.parse(fetchCalls[1].opts.body); assert.equal(call2Body.files.length, 1); assert.equal(call2Body.files[0].name, 'second.pdf'); // Сессия, созданная на 1 шаге, проброшена во 2 шаг assert.equal(call2Body.session, 'created-sid-1'); assert.equal(completed.length, 2); assert.equal(completed[0].name, 'first.txt'); assert.equal(completed[1].name, 'second.pdf'); }); test('uploadViaVM: поддержка формата FilePicker.getFiles() ({name, size, file, path})', async () => { installFetch(async (url, opts) => { return { ok: true, json: async () => ({ ok: true, session: 'sid-p', count: 1 }), }; }); const pickerFiles = [ { name: 'archive_doc.txt', size: 42, file: new File(['x'.repeat(42)], 'archive_doc.txt'), path: 'folder/archive_doc.txt', }, ]; const p = uploadViaVM(pickerFiles, { vmUploadUrl: 'https://vm-buffer/upload/', }); await tick(); lastXHR.status = 200; lastXHR.onload(); const res = await p; assert.equal(res.ok, true); assert.equal(res.count, 1); const body = JSON.parse(fetchCalls[0].opts.body); assert.equal(body.files[0].name, 'archive_doc.txt'); assert.equal(body.files[0].size, 42); }); test('uploadViaVM: обработка ошибки PUT', async () => { installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) })); const files = [new File(['bad'], 'err.txt')]; const p = uploadViaVM(files, { vmUploadUrl: 'https://vm-buffer/upload/', }); await tick(); lastXHR.onerror(); const res = await p; assert.equal(res.ok, false); assert.ok(res.error.includes('Ошибка отправки файла')); assert.equal(fetchCalls.length, 0); // POST не вызывался }); test('uploadViaVM: прерывание через signal', async () => { installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) })); const ac = new AbortController(); const files = [new File(['1'], 'f1.txt'), new File(['2'], 'f2.txt')]; const p = uploadViaVM(files, { vmUploadUrl: 'https://vm-buffer/upload/', signal: ac.signal, }); await tick(); ac.abort(); const res = await p; assert.equal(res.ok, false); assert.equal(res.aborted, true); }); test('uploadViaVM: отмена после первого файла не начинает второй PUT', async () => { installFetch(async (url, opts) => ({ ok: true, json: async () => ({ ok: true, session: 'partial-sid', count: 1 }), })); const ac = new AbortController(); const statuses = []; const files = [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')]; const p = uploadViaVM(files, { vmUploadUrl: 'https://vm-buffer/upload/', signal: ac.signal, onFileStatus: (idx, status) => statuses.push({ idx, status }), }); await tick(); assert.equal(lastXHR.url.endsWith('_0'), true); lastXHR.status = 201; lastXHR.onload(); ac.abort(); const res = await p; assert.equal(res.ok, false); assert.equal(res.aborted, true); assert.equal(fetchCalls.length, 1); assert.equal(lastXHR.url.endsWith('_0'), true); assert.ok(statuses.some(({ idx, status }) => idx === 0 && status.includes('Доставлен'))); });