From 52c3eb53f187f4770cd1b8c6a0dd660f42be52e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 25 Aug 2026 19:11:29 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5/=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=81=D0=BB=D0=BE=D1=91=D0=B2?= =?UTF-8?q?=201=20=D0=B8=202=20(37=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_upload_module.py | 79 ++++++++++ upload/frontend/test_upload_frontend.mjs | 11 ++ upload/frontend/test_upload_layer2.mjs | 188 +++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 upload/frontend/test_upload_layer2.mjs diff --git a/tests/test_upload_module.py b/tests/test_upload_module.py index e35b87d..dce6d59 100644 --- a/tests/test_upload_module.py +++ b/tests/test_upload_module.py @@ -7,6 +7,7 @@ import os import sys +import time # Корень проекта — для импорта пакета upload sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -227,6 +228,84 @@ def test_upload_refs_pull_failed(monkeypatch): assert rv.status_code == 502 # все ретраи провалились +def test_session_many_files(): + """Нагрузка: 1000 файлов в одной сессии.""" + sid = create_session() + for i in range(1000): + assert add_file(sid, "f%d.txt" % i, b"x" * 10) is True + assert file_count(sid) == 1000 + assert len(get_files(sid)) == 1000 + + +def test_concurrent_add_files(): + """Конкурентность: 4 потока добавляют файлы в общую сессию.""" + import threading + sid = create_session() + + def worker(prefix): + for i in range(50): + add_file(sid, "%s_%d.txt" % (prefix, i), b"data") + + threads = [threading.Thread(target=worker, args=("t%d" % t,)) for t in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert file_count(sid) == 200 + + +def test_session_ttl_cleanup(): + """TTL: короткий TTL → сессия очищается фоновым таймером (долгий тест).""" + from upload.backend.session import configure + configure(ttl_seconds=1) + try: + sid = create_session() + assert add_file(sid, "a.txt", b"x") is True + time.sleep(1.5) + assert get_files(sid) is None # таймер удалил сессию + finally: + configure(ttl_seconds=30 * 60) + + +def test_add_file_session_limit_boundary(): + """Суммарный лимит: граница ровно на MAX_SESSION_BYTES.""" + from upload.backend.session import configure + configure(max_session_bytes=1000) + try: + sid = create_session() + assert add_file(sid, "a", b"x" * 500) is True + assert add_file(sid, "b", b"x" * 500) is True # ровно 1000 + assert add_file(sid, "c", b"x") is False # 1001 > 1000 + assert file_count(sid) == 2 + finally: + configure(max_session_bytes=500 * 1024 * 1024) + + +def test_safe_name_edge_cases(): + """safe_name: граничные случаи.""" + assert safe_name("...") == "..." + assert safe_name("a/../b") == "" + assert safe_name("a//b") == "a/b" + assert safe_name(" ") == " " + assert safe_name("договор/файл.pdf") == "договор/файл.pdf" + assert safe_name("C:\\path\\file") == "C:/path/file" + assert safe_name("/abs/path") == "abs/path" + + +def test_upload_refs_many_files(monkeypatch): + """Нагрузка: 100 refs → все pull OK, count=100.""" + behavior = {} + files = [] + for i in range(100): + url = VM_PREFIX + "tok_%d" % i + behavior[url] = {"payload": b"x" * 10} + files.append({"name": "f%d.txt" % i, "size": 10, "url": url}) + c = _make_client(behavior, monkeypatch=monkeypatch) + rv = c.post("/api/upload_refs", json={"session": "", "files": files}) + assert rv.status_code == 200 + assert rv.get_json()["count"] == 100 + + # ═══════════════════════════════════════════════════════════════════════════ # main (запуск без pytest) # ═══════════════════════════════════════════════════════════════════════════ diff --git a/upload/frontend/test_upload_frontend.mjs b/upload/frontend/test_upload_frontend.mjs index efbced6..bf4b1d4 100644 --- a/upload/frontend/test_upload_frontend.mjs +++ b/upload/frontend/test_upload_frontend.mjs @@ -184,6 +184,16 @@ function testLimits() { console.log(' лимиты over: ok'); } +function testSuffixes() { + const st = newState(); + const c = { maxFileBytes: 10000, maxSessionBytes: 100000 }; + addFileWithDedup(st, new File(['aaa'], 'a.txt'), c); + addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt + addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt + assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']); + console.log(' суффиксы _2/_3: ok'); +} + // ── Прогон ── const TESTS = [ ['decodeZipName', testDecodeZipName], @@ -193,6 +203,7 @@ const TESTS = [ ['не-ZIP бросок', testParseZipNotZip], ['дедуп/суффиксы', testDedup], ['лимиты over', testLimits], + ['суффиксы _2/_3', testSuffixes], ]; let failed = 0; diff --git a/upload/frontend/test_upload_layer2.mjs b/upload/frontend/test_upload_layer2.mjs new file mode 100644 index 0000000..f7d0409 --- /dev/null +++ b/upload/frontend/test_upload_layer2.mjs @@ -0,0 +1,188 @@ +// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch. +// Запуск: node upload/frontend/test_upload_layer2.mjs + +import assert from 'node:assert/strict'; + +// ── Полифилл File (Node 18 не имеет global File) ── +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/put_to_vm.js'; +import { uploadViaVM } from './upload/upload_via_vm.js'; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM +globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' }; + +// ── мок 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.method = null; + this.url = null; + this.body = null; + lastXHR = this; + } + open(method, url) { this.method = method; this.url = url; } + send(body) { this.body = body; } +} +globalThis.XMLHttpRequest = FakeXHR; + +// ── мок fetch ── +let fetchCalls = []; +function installFetch(respond) { + fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push({ url, opts }); + return respond(); + }; +} + +// ── putToVm ── +async function testPutToVmSuccess() { + const f = new File(['abc'], 'a.txt'); + const p = putToVm(f, 'https://vm/tok_0'); + lastXHR.status = 200; + lastXHR.onload(); + await p; + assert.equal(lastXHR.method, 'PUT'); + assert.equal(lastXHR.url, 'https://vm/tok_0'); + assert.equal(lastXHR.body, f); + console.log(' putToVm success: ok'); +} + +async function testPutToVmHttpError() { + const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0'); + lastXHR.status = 500; + lastXHR.onload(); + await assert.rejects(p, /ВМ: HTTP 500/); + console.log(' putToVm HTTP error: ok'); +} + +async function testPutToVmNetworkError() { + const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0'); + lastXHR.onerror(); + await assert.rejects(p, /Сеть/); + console.log(' putToVm network error: ok'); +} + +async function testPutToVmTimeout() { + const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0'); + lastXHR.ontimeout(); + await assert.rejects(p, /Таймаут/); + console.log(' putToVm timeout: ok'); +} + +// ── uploadViaVM ── +async function testUploadViaVMSuccess() { + installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) })); + const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')]; + const p = uploadViaVM(files, 'https://vm/', { session: '' }); + await tick(); + lastXHR.status = 200; lastXHR.onload(); + await tick(); + lastXHR.status = 200; lastXHR.onload(); + const res = await p; + assert.equal(res.ok, true); + assert.equal(res.session, 'sid123'); + assert.equal(res.count, 2); + assert.equal(fetchCalls.length, 1); + const body = JSON.parse(fetchCalls[0].opts.body); + assert.equal(body.files.length, 2); + assert.equal(body.files[0].name, 'a.txt'); + assert.equal(body.files[1].name, 'b.txt'); + assert.ok(body.files[0].url.startsWith('https://vm/')); + console.log(' uploadViaVM success: ok'); +} + +async function testUploadViaVMPutError() { + installFetch(() => ({ ok: true, json: async () => ({}) })); + const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')]; + const p = uploadViaVM(files, 'https://vm/', { session: '' }); + await tick(); + lastXHR.onerror(); // первый PUT падает + const res = await p; + assert.equal(res.ok, false); + assert.ok(res.error.includes('Ошибка загрузки на ВМ')); + assert.equal(fetchCalls.length, 0); // POST не вызван + console.log(' uploadViaVM PUT error: ok'); +} + +async function testUploadViaVMPostError() { + installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) })); + const files = [new File(['a'], 'a.txt')]; + const p = uploadViaVM(files, 'https://vm/', { session: '' }); + await tick(); + lastXHR.status = 200; lastXHR.onload(); + const res = await p; + assert.equal(res.ok, false); + assert.ok(res.error.includes('Ошибка передачи ссылок')); + console.log(' uploadViaVM POST error: ok'); +} + +async function testUploadViaVMProgress() { + installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) })); + const statuses = []; + const texts = []; + const files = [new File(['abc'], 'a.txt')]; + const p = uploadViaVM(files, 'https://vm/', { + session: '', + onStatus(k, html) { statuses.push({ k, html }); }, + onUploadStatus(t) { texts.push(t); }, + }); + await tick(); + lastXHR.status = 200; lastXHR.onload(); + await p; + assert.ok(statuses.some(s => s.html.includes('✓'))); + assert.ok(texts.some(t => t.includes('Загрузка на ВМ'))); + assert.ok(texts.some(t => t.includes('Передача ссылок'))); + console.log(' uploadViaVM progress/статусы: ok'); +} + +async function testUploadViaVMSessionPassed() { + // session из options пробрасывается в POST-тело + installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) })); + const files = [new File(['a'], 'a.txt')]; + const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' }); + await tick(); + lastXHR.status = 200; lastXHR.onload(); + await p; + const body = JSON.parse(fetchCalls[0].opts.body); + assert.equal(body.session, 'existingsid'); + console.log(' uploadViaVM проброс session: ok'); +} + +// ── прогон ── +const TESTS = [ + ['putToVm success', testPutToVmSuccess], + ['putToVm HTTP error', testPutToVmHttpError], + ['putToVm network error', testPutToVmNetworkError], + ['putToVm timeout', testPutToVmTimeout], + ['uploadViaVM success', testUploadViaVMSuccess], + ['uploadViaVM PUT error', testUploadViaVMPutError], + ['uploadViaVM POST error', testUploadViaVMPostError], + ['uploadViaVM progress', testUploadViaVMProgress], + ['uploadViaVM проброс session', testUploadViaVMSessionPassed], +]; + +let failed = 0; +for (const [name, fn] of TESTS) { + try { await fn(); console.log('PASS ' + name); } + catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); } +} +if (failed) { console.error(failed + ' тестов упало'); process.exit(1); } +console.log('Все тесты слоя 2 (фронт) прошли');