""" Concurrent / load tests — verify the API handles multiple simultaneous requests without race conditions or resource leaks. These tests do NOT require Redis or Celery; every external call is mocked. """ import io import threading from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- # Rapid sequential requests — baseline stability # --------------------------------------------------------------------------- class TestRapidSequential: def test_100_health_requests(self, client): """100 back-to-back /health requests must all return 200.""" for _ in range(100): r = client.get('/api/health') assert r.status_code == 200 def test_rapid_no_file_errors_are_safe(self, client): """50 rapid requests that each produce a 400 must not leak state.""" for _ in range(50): r = client.post('/api/compress/pdf') assert r.status_code == 400 assert r.get_json()['error'] # --------------------------------------------------------------------------- # Concurrent requests — 10 simultaneous threads, each with its own client # --------------------------------------------------------------------------- class TestConcurrentRequests: def test_10_concurrent_health(self, app): """10 threads hitting /health simultaneously must all get 200.""" results: list[int] = [] errors: list[Exception] = [] lock = threading.Lock() def worker(): try: with app.test_client() as c: r = c.get('/api/health') with lock: results.append(r.status_code) except Exception as exc: with lock: errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(10)] for t in threads: t.start() for t in threads: t.join(timeout=10) assert not errors, f"Threads raised: {errors}" assert results.count(200) == 10 def test_concurrent_compress_uploads(self, app): """5 concurrent compress requests each return 202 without deadlocks. Patches are applied ONCE outside threads to avoid thread-safety issues with unittest.mock's global state.""" task_ids: list[str] = [] errors: list[Exception] = [] lock = threading.Lock() # Use a counter-based side_effect so the shared mock returns distinct ids counter = [0] def make_task(): with lock: n = counter[0] counter[0] += 1 t = MagicMock() t.id = f'task-thread-{n}' return t # Apply all patches BEFORE threads start — avoids concurrent patch/unpatch with patch('app.routes.compress.validate_actor_file', return_value=('t.pdf', 'pdf')), \ patch('app.routes.compress.generate_safe_path', side_effect=lambda ext, folder_type: (f'tid-x', '/tmp/up/t.pdf')), \ patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ patch('app.routes.compress.compress_pdf_task.delay', side_effect=lambda *a, **kw: make_task()): def worker(): try: with app.test_client() as c: r = c.post( '/api/compress/pdf', data={'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf')}, content_type='multipart/form-data', ) with lock: if r.status_code == 202: task_ids.append(r.get_json()['task_id']) else: errors.append( AssertionError( f"expected 202, got {r.status_code}: {r.data}" ) ) except Exception as exc: with lock: errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(5)] for t in threads: t.start() for t in threads: t.join(timeout=15) assert not errors, f"Errors in threads: {errors}" assert len(task_ids) == 5 assert len(set(task_ids)) == 5, "task_ids must be unique per request" def test_concurrent_pdf_tools_requests(self, app): """3 concurrent split-PDF requests must not interfere with each other. Patches applied once outside threads for thread safety.""" statuses: list[int] = [] errors: list[Exception] = [] lock = threading.Lock() with patch('app.routes.pdf_tools.validate_actor_file', return_value=('t.pdf', 'pdf')), \ patch('app.routes.pdf_tools.generate_safe_path', side_effect=lambda ext, folder_type: ('split-x', '/tmp/up/t.pdf')), \ patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ patch('app.routes.pdf_tools.split_pdf_task.delay', return_value=MagicMock(id='split-task')): def worker(): try: with app.test_client() as c: r = c.post( '/api/pdf-tools/split', data={ 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), 'mode': 'all', }, content_type='multipart/form-data', ) with lock: statuses.append(r.status_code) except Exception as exc: with lock: errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(3)] for t in threads: t.start() for t in threads: t.join(timeout=15) assert not errors, f"Errors in threads: {errors}" assert all(s == 202 for s in statuses), f"Got statuses: {statuses}" # --------------------------------------------------------------------------- # File-size enforcement # --------------------------------------------------------------------------- class TestFileSizeLimits: def test_compress_rejects_oversized_request(self, client, app): """Requests exceeding MAX_CONTENT_LENGTH must be rejected (413).""" original = app.config['MAX_CONTENT_LENGTH'] try: # Set 1-byte limit so any real file triggers it app.config['MAX_CONTENT_LENGTH'] = 1 oversized = io.BytesIO(b'%PDF-1.4' + b'x' * 2048) r = client.post( '/api/compress/pdf', data={'file': (oversized, 'huge.pdf')}, content_type='multipart/form-data', ) assert r.status_code in (400, 413), ( f"Expected 400 or 413 for oversized file, got {r.status_code}" ) finally: app.config['MAX_CONTENT_LENGTH'] = original def test_normal_size_file_is_accepted(self, client, monkeypatch): """A file within the size limit reaches the route logic.""" monkeypatch.setattr( 'app.routes.compress.validate_actor_file', lambda f, allowed_types, actor: ('t.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.compress.generate_safe_path', lambda ext, folder_type: ('tid', '/tmp/test_uploads/tid/t.pdf'), ) monkeypatch.setattr( 'werkzeug.datastructures.file_storage.FileStorage.save', lambda self, dst, buffer_size=16384: None, ) mock_task = MagicMock() mock_task.id = 'size-ok-task' monkeypatch.setattr( 'app.routes.compress.compress_pdf_task.delay', MagicMock(return_value=mock_task), ) small_pdf = io.BytesIO(b'%PDF-1.4 small') r = client.post( '/api/compress/pdf', data={'file': (small_pdf, 'small.pdf')}, content_type='multipart/form-data', ) assert r.status_code == 202