import asyncio import json import os import sys from pathlib import Path import httpx import pytest from app import archive_auth, archive_download, bt, main, store @pytest.fixture def isolated(tmp_path, monkeypatch): monkeypatch.setattr(store, 'DATA', tmp_path) monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3') monkeypatch.setenv('ARIA2_SECRET', 'test') monkeypatch.setenv('ARCHIVE_CONNECTIONS', '1') # Old single-stream behavior is covered separately. store.init_db() return tmp_path def create_archive(url='https://archive.org/download/item/Game.iso'): return main.new_job('url', url, 'Game', 'raw') def fake_http_client(monkeypatch, handler): transport = httpx.MockTransport(handler) monkeypatch.setattr(archive_download, '_client', lambda: httpx.Client( transport=transport, cookies=archive_auth.session_cookies(), follow_redirects=False)) monkeypatch.setattr(archive_download.time, 'sleep', lambda delay: None) class BytesStream(httpx.SyncByteStream): def __init__(self, data): self.data = data def __iter__(self): yield self.data def test_archive_transient_http_500_is_retried_and_logged(isolated, monkeypatch): archive_auth._save_session('user@example.org', { 'logged-in-user': 'user', 'logged-in-sig': 'cookie-secret'}) requests = [] def handler(request): requests.append(request) if len(requests) < 3: return httpx.Response(500, text='upstream temporarily broken') return httpx.Response(200, stream=BytesStream(b'ISO-CONTENT'), headers={'Content-Length': '11'}) fake_http_client(monkeypatch, handler) job = create_archive() archive_download._transfer_one(job) assert store.get(job['id'])['state'] == 'preparing' assert (isolated / 'raw' / job['id'] / 'Game.iso').read_bytes() == b'ISO-CONTENT' assert len(requests) == 3 assert all('cookie-secret' in req.headers.get('cookie', '') for req in requests) text = (isolated / 'logs' / (job['id'] + '.download.log')).read_text() assert 'HTTP 500' in text and 'Ukończono' in text assert 'cookie-secret' not in text def test_archive_range_resume_with_redirect(isolated, monkeypatch): requests = [] def handler(request): requests.append((request.url.host, request.headers.get('range'))) if request.url.host == 'archive.org': return httpx.Response(302, headers={'Location': 'https://ia444.us.archive.org/download/item/Game.iso'}) return httpx.Response(206, stream=BytesStream(b'DEF'), headers={'Content-Range': 'bytes 3-5/6', 'Content-Length': '3'}) fake_http_client(monkeypatch, handler) job = create_archive() raw = isolated / 'raw' / job['id'] raw.mkdir(parents=True) (raw / 'Game.iso.part').write_bytes(b'ABC') archive_download._transfer_one(job) assert (raw / 'Game.iso').read_bytes() == b'ABCDEF' assert not (raw / 'Game.iso.part').exists() assert requests == [('archive.org', 'bytes=3-'), ('ia444.us.archive.org', 'bytes=3-')] def test_archive_403_is_explicit_and_not_retried(isolated, monkeypatch): seen = [] def handler(request): seen.append(1) return httpx.Response(403, text='denied') fake_http_client(monkeypatch, handler) job = create_archive() with pytest.raises(archive_download.ArchiveDownloadError, match='HTTP 403'): archive_download._transfer_one(job) assert len(seen) == 1 def test_archive_persistent_500_reports_origin_failure(isolated, monkeypatch): seen = [] def handler(request): seen.append(1) return httpx.Response(500) fake_http_client(monkeypatch, handler) job = create_archive() with pytest.raises(archive_download.ArchiveDownloadError, match='HTTP 500 po 5 próbach'): archive_download._transfer_one(job) assert len(seen) == 5 def test_archive_cancel_keeps_partial_files(isolated, monkeypatch): job = create_archive() store.update(job['id'], state='paused') fake_http_client(monkeypatch, lambda req: pytest.fail('Do not download paused job')) with pytest.raises(archive_download.ArchiveStopped): archive_download._transfer_one(job) def test_bt_helper_subprocess_inspection_and_fetch(isolated, monkeypatch): """Exercise process supervisor and status semantics without real peers.""" from tests.test_features import sample_torrent helper = isolated / 'fake-helper' # Tests the Python supervisor lifecycle, not the compiled Go / network engine. helper.write_text('#!' + sys.executable + '\n' + ''' import json, os, sys from pathlib import Path args = sys.argv[1:] mode = args[0] def param(x): return args[args.index(x)+1] state = Path(param('-status')) state.parent.mkdir(parents=True, exist_ok=True) if mode == 'inspect': Path(param('-out')).write_bytes(bytes.fromhex(os.environ['FAKE_TORRENT_HEX'])) state.write_text(json.dumps({'status':'ready','connections':2})) else: output=Path(param('-out')) output.mkdir(parents=True, exist_ok=True) (output/'Game.iso').write_bytes(b'foo') state.write_text(json.dumps({'status':'done','downloaded':3,'total':3})) ''') helper.chmod(0o755) monkeypatch.setenv('FAKE_TORRENT_HEX', sample_torrent().hex()) monkeypatch.setenv('BT_HELPER', str(helper)) monkeypatch.setattr(bt, '_DOWNLOADS', {}) monkeypatch.setattr(bt, '_INSPECTIONS', {}) async def check(): pid = 'a' * 32 folder = isolated / 'metadata' / pid folder.mkdir(parents=True) proc = await bt.start_inspection(pid, 'magnet:?xt=urn:btih:' + 'a'*40) assert await proc.wait() == 0 assert bt.inspect_state(pid)['status'] == 'ready' await bt.stop_inspection(pid) job = main.new_job('torrent_selected', json.dumps({'selection':'1'}), 'Game', 'raw') (isolated/'torrents').mkdir() (isolated/'torrents'/(job['id']+'.torrent')).write_bytes(sample_torrent()) await bt.start_download(job) proc = bt._DOWNLOADS[job['id']] assert await proc.wait() == 0 await bt.poll_download(store.get(job['id'])) assert store.get(job['id'])['state'] == 'preparing' await bt.shutdown() asyncio.run(check())