Download tests/test_bulk_async.py from sitcod3/modul-x: direct link, hf CLI and curl.
- Browser
- Download file 5.89 kB
-
https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/tests/test_bulk_async.py
- Command line
-
hf download hf://spaces/sitcod3/modul-x/tests/test_bulk_async.py
-
curl -L -o test_bulk_async.py https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/tests/test_bulk_async.py
5.89 kB
| import json | |
| import unittest | |
| from unittest.mock import AsyncMock, MagicMock, patch | |
| from fastapi.testclient import TestClient | |
| from app.main import app | |
| client = TestClient(app) | |
| class BulkAsyncTests(unittest.TestCase): | |
| """The sync bulk fallback must NOT block the HTTP request: it should | |
| return a task_id immediately, process in the background, and let the | |
| frontend poll /validate/bulk/status/{task_id} for progress + results. | |
| The blocking behavior caused the frontend's 45s fetch timeout, which is | |
| why the Results page showed nothing.""" | |
| def setUp(self): | |
| self.captured = {} | |
| async def fake_process(emails, webhook_url=None, deep=True): | |
| self.captured["emails"] = list(emails) | |
| self.captured["deep"] = deep | |
| # Realistic shape: exactly what engine.validate_batch returns. | |
| return [ | |
| { | |
| "email": e, "status": "risky", "score": 70, | |
| "syntax": {"passed": True}, | |
| "scoring": {"score": 70, "risk_level": "medium", | |
| "breakdown": {}, "warnings": []}, | |
| "processing_time_ms": 1.0, | |
| "validated_at": "2026-01-01T00:00:00+00:00", | |
| } | |
| for e in emails | |
| ] | |
| self.patchers = [ | |
| patch("app.api.routes.process_bulk_sync", new=AsyncMock(side_effect=fake_process)), | |
| patch("app.utils.cache.redis_available", new=AsyncMock(return_value=False)), | |
| patch("app.api.routes.settings.enable_db_persistence", False), | |
| # Skip the Redis connection attempt so the in-memory fallback is | |
| # instant — otherwise the background task blocks ~4s per cache call. | |
| patch("app.utils.cache.get_redis", new=AsyncMock(return_value=None)), | |
| ] | |
| for p in self.patchers: | |
| p.start() | |
| def tearDown(self): | |
| for p in self.patchers: | |
| p.stop() | |
| def test_bulk_returns_immediately_with_task_id(self): | |
| import time | |
| t0 = time.time() | |
| r = client.post("/api/v1/validate/bulk", json={"emails": ["a@b.test", "c@d.test"]}) | |
| elapsed = time.time() - t0 | |
| self.assertEqual(r.status_code, 200) | |
| body = r.json() | |
| self.assertIn("task_id", body) | |
| self.assertNotEqual(body["task_id"], "sync", "must return a REAL task id") | |
| self.assertEqual(body["status"], "processing") | |
| self.assertLess(elapsed, 2.0, f"bulk blocked for {elapsed:.1f}s - must return immediately") | |
| def test_background_results_are_stored_for_polling(self): | |
| import asyncio | |
| from app.utils.cache import _memory_cache | |
| _memory_cache.clear() | |
| r = client.post("/api/v1/validate/bulk", json={"emails": ["a@b.test"]}) | |
| task_id = r.json()["task_id"] | |
| # Give the background task a moment to complete. | |
| import time | |
| time.sleep(0.5) | |
| stored = _memory_cache.get(f"bulk_result:{task_id}") | |
| self.assertIsNotNone(stored, "results must be stored for polling") | |
| # _memory_set stores (value, expires) tuples — unwrap to the payload. | |
| if isinstance(stored, tuple): | |
| stored = stored[0] | |
| data = json.loads(stored) | |
| self.assertEqual(data["status"], "completed") | |
| self.assertEqual(len(data["results"]), 1) | |
| def test_status_endpoint_returns_stored_results(self): | |
| from app.utils.cache import _memory_cache | |
| _memory_cache.clear() | |
| r = client.post("/api/v1/validate/bulk", json={"emails": ["a@b.test"]}) | |
| task_id = r.json()["task_id"] | |
| import time | |
| time.sleep(0.5) | |
| st = client.get(f"/api/v1/validate/bulk/status/{task_id}") | |
| self.assertEqual(st.status_code, 200) | |
| body = st.json() | |
| self.assertEqual(body["status"], "completed") | |
| self.assertEqual(len(body["results"]), 1) | |
| def test_redis_available_but_flag_off_uses_background_path(self): | |
| """Redis being reachable must NOT switch bulk to Celery on its own. | |
| The deployed Space runs uvicorn only (Dockerfile CMD) — no Celery | |
| worker. If a live Redis alone selected the Celery branch, every bulk | |
| job would sit in the queue forever and the Results page would show | |
| "processing" indefinitely. The explicit flag (default off) is the | |
| opt-in for hosts that actually run a worker. | |
| """ | |
| with ( | |
| patch("app.utils.cache.redis_available", | |
| new=AsyncMock(return_value=True)), | |
| patch("app.api.routes.settings.enable_db_persistence", False), | |
| patch("app.api.routes.settings.celery_bulk_enabled", False), | |
| ): | |
| r = client.post("/api/v1/validate/bulk", | |
| json={"emails": ["a@b.test"]}) | |
| self.assertEqual(r.status_code, 200) | |
| body = r.json() | |
| self.assertNotEqual(body["task_id"], "sync") | |
| self.assertIn("status", body) | |
| def test_flag_on_and_redis_available_uses_celery(self): | |
| from app.tasks.celery_tasks import bulk_validate_task # noqa: F401 | |
| fake_task = MagicMock() | |
| fake_task.id = "celery-123" | |
| with ( | |
| patch("app.utils.cache.redis_available", | |
| new=AsyncMock(return_value=True)), | |
| patch("app.api.routes.settings.enable_db_persistence", False), | |
| patch("app.api.routes.settings.celery_bulk_enabled", True), | |
| patch("app.api.routes.bulk_validate_task.delay", | |
| return_value=fake_task) as delay_mock, | |
| ): | |
| r = client.post("/api/v1/validate/bulk", | |
| json={"emails": ["a@b.test"]}) | |
| self.assertEqual(r.status_code, 200) | |
| self.assertEqual(r.json()["task_id"], "celery-123") | |
| delay_mock.assert_called_once() | |
| if __name__ == "__main__": | |
| unittest.main() |