Spaces:
Running
Running
| """Coverage for animetix.tasks_views — Cloud Tasks / Workflow / Eventarc endpoints. | |
| These are plain ``csrf_exempt`` Django views (not DRF). They are exercised | |
| through the Django test ``Client`` via ``reverse()``. Every external collaborator | |
| is mocked: | |
| * ``run_task_view`` -> registered task funcs (via tasks_registry), cache | |
| * ``poll_workflow_view`` -> GCPWorkflowsClient, cache | |
| * ``eventarc_gcs_upload_view`` -> enqueue_task, cache | |
| Production-only OIDC branches are reached with ``override_settings`` flipping | |
| ``IS_PRODUCTION`` and the relevant audience URLs, while | |
| ``google.oauth2.id_token.verify_oauth2_token`` is patched to accept/reject. | |
| """ | |
| import json | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from animetix import tasks_views as tv | |
| from django.core.cache import cache | |
| from django.test import Client, RequestFactory, override_settings | |
| from django.urls import reverse | |
| def client(): | |
| return Client() | |
| def rf(): | |
| return RequestFactory() | |
| def _post_run_task(rf, payload=None, raw=None, **extra): | |
| """Drive ``run_task_view`` directly. | |
| The project routes ``/api/tasks/run/`` to a RedirectView *before* the real | |
| view in ``urls.py``, so dispatching through the URL resolver yields a 302. | |
| We therefore call the view function directly with a RequestFactory request. | |
| """ | |
| body = raw if raw is not None else json.dumps(payload or {}) | |
| request = rf.post( | |
| "/api/tasks/run/", data=body, content_type="application/json", **extra | |
| ) | |
| return tv.run_task_view(request) | |
| def _clear_cache(): | |
| cache.clear() | |
| yield | |
| cache.clear() | |
| # --------------------------------------------------------------------------- # | |
| # run_task_view | |
| # --------------------------------------------------------------------------- # | |
| def _body(response): | |
| return json.loads(response.content) | |
| def test_run_task_method_not_allowed(rf): | |
| response = tv.run_task_view(rf.get("/api/tasks/run/")) | |
| assert response.status_code == 405 | |
| assert _body(response)["error"] == "Method not allowed" | |
| def test_run_task_invalid_json(rf): | |
| response = _post_run_task(rf, raw="not-json") | |
| assert response.status_code == 400 | |
| assert _body(response)["error"] == "Invalid JSON payload" | |
| def test_run_task_missing_fields(rf): | |
| response = _post_run_task(rf, {"task_id": "abc"}) | |
| assert response.status_code == 400 | |
| assert "required" in _body(response)["error"] | |
| def test_run_task_unregistered(rf): | |
| response = _post_run_task(rf, {"task_id": "id1", "task_name": "does_not_exist"}) | |
| assert response.status_code == 400 | |
| assert "not registered" in _body(response)["error"] | |
| # Failure result is persisted in the cache for later polling. | |
| cached = cache.get("task_result:id1") | |
| assert cached["state"] == "FAILURE" | |
| assert "not registered" in cached["result"]["error"] | |
| def test_run_task_success(rf): | |
| task = MagicMock(return_value={"ok": 42}) | |
| with patch.object(tv, "get_registered_task", return_value=task): | |
| response = _post_run_task( | |
| rf, | |
| { | |
| "task_id": "id2", | |
| "task_name": "my_task", | |
| "args": [1, 2], | |
| "kwargs": {"x": "y"}, | |
| }, | |
| ) | |
| assert response.status_code == 200 | |
| assert _body(response) == {"status": "success", "task_id": "id2"} | |
| task.assert_called_once_with(1, 2, x="y") | |
| cached = cache.get("task_result:id2") | |
| assert cached["state"] == "SUCCESS" | |
| assert cached["result"] == {"ok": 42} | |
| def test_run_task_execution_error(rf): | |
| task = MagicMock(side_effect=RuntimeError("boom")) | |
| with patch.object(tv, "get_registered_task", return_value=task): | |
| response = _post_run_task(rf, {"task_id": "id3", "task_name": "my_task"}) | |
| # 500 so Cloud Tasks retries. | |
| assert response.status_code == 500 | |
| assert _body(response)["error"] == "boom" | |
| cached = cache.get("task_result:id3") | |
| assert cached["state"] == "FAILURE" | |
| assert cached["result"]["error"] == "boom" | |
| def test_run_task_prod_missing_auth(rf): | |
| response = _post_run_task(rf, {"task_id": "id", "task_name": "t"}) | |
| assert response.status_code == 401 | |
| def test_run_task_prod_bad_token(rf): | |
| with patch( | |
| "google.oauth2.id_token.verify_oauth2_token", | |
| side_effect=ValueError("bad token"), | |
| ): | |
| response = _post_run_task( | |
| rf, | |
| {"task_id": "id", "task_name": "t"}, | |
| HTTP_AUTHORIZATION="Bearer faketoken", | |
| ) | |
| assert response.status_code == 403 | |
| assert _body(response)["error"] == "Invalid OIDC token" | |
| def test_run_task_prod_valid_token(rf): | |
| task = MagicMock(return_value={"done": True}) | |
| with ( | |
| patch( | |
| "google.oauth2.id_token.verify_oauth2_token", return_value={"sub": "svc"} | |
| ), | |
| patch.object(tv, "get_registered_task", return_value=task), | |
| ): | |
| response = _post_run_task( | |
| rf, | |
| {"task_id": "p1", "task_name": "t"}, | |
| HTTP_AUTHORIZATION="Bearer goodtoken", | |
| ) | |
| assert response.status_code == 200 | |
| assert cache.get("task_result:p1")["state"] == "SUCCESS" | |
| # --------------------------------------------------------------------------- # | |
| # poll_workflow_view | |
| # --------------------------------------------------------------------------- # | |
| def test_poll_workflow_method_not_allowed(client): | |
| response = client.get(reverse("poll-workflow")) | |
| assert response.status_code == 405 | |
| def test_poll_workflow_invalid_json(client): | |
| response = client.post( | |
| reverse("poll-workflow"), data="{bad", content_type="application/json" | |
| ) | |
| assert response.status_code == 400 | |
| assert response.json()["error"] == "Invalid payload" | |
| def test_poll_workflow_missing_fields(client): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"task_id": "t"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 400 | |
| assert "Missing" in response.json()["error"] | |
| def test_poll_workflow_active(client): | |
| wf = MagicMock() | |
| wf.get_execution_status.return_value = {"state": "ACTIVE"} | |
| with patch.object(tv, "GCPWorkflowsClient", return_value=wf): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "exec/1", "task_id": "t1"}), | |
| content_type="application/json", | |
| ) | |
| # 503 makes Cloud Tasks retry. | |
| assert response.status_code == 503 | |
| wf.get_execution_status.assert_called_once_with("exec/1") | |
| def test_poll_workflow_succeeded(client): | |
| wf = MagicMock() | |
| wf.get_execution_status.return_value = { | |
| "state": "SUCCEEDED", | |
| "result": {"translated_text": "hola", "audio_url": "http://a"}, | |
| } | |
| with patch.object(tv, "GCPWorkflowsClient", return_value=wf): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "exec/2", "task_id": "t2"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.json()["status"] == "completed" | |
| cached = cache.get("task_result:t2") | |
| assert cached["status"] == "success" | |
| assert cached["result"]["translated_text"] == "hola" | |
| assert cached["result"]["audio_url"] == "http://a" | |
| def test_poll_workflow_failed(client): | |
| wf = MagicMock() | |
| wf.get_execution_status.return_value = {"state": "FAILED", "error": "nope"} | |
| with patch.object(tv, "GCPWorkflowsClient", return_value=wf): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "exec/3", "task_id": "t3"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.json()["status"] == "failed" | |
| cached = cache.get("task_result:t3") | |
| assert cached["status"] == "failed" | |
| assert cached["error"] == "nope" | |
| def test_poll_workflow_prod_missing_auth(client): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "e", "task_id": "t"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 401 | |
| assert response.json()["error"] == "Unauthorized" | |
| def test_poll_workflow_prod_bad_token(client): | |
| with patch( | |
| "animetix.tasks_views.id_token.verify_oauth2_token", | |
| side_effect=ValueError("bad"), | |
| ): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "e", "task_id": "t"}), | |
| content_type="application/json", | |
| HTTP_AUTHORIZATION="Bearer x", | |
| ) | |
| assert response.status_code == 403 | |
| assert response.json()["error"] == "Forbidden" | |
| def test_poll_workflow_prod_valid_token(client): | |
| wf = MagicMock() | |
| wf.get_execution_status.return_value = {"state": "ACTIVE"} | |
| with ( | |
| patch("animetix.tasks_views.id_token.verify_oauth2_token", return_value={}), | |
| patch.object(tv, "GCPWorkflowsClient", return_value=wf), | |
| ): | |
| response = client.post( | |
| reverse("poll-workflow"), | |
| data=json.dumps({"execution_name": "e", "task_id": "t"}), | |
| content_type="application/json", | |
| HTTP_AUTHORIZATION="Bearer good", | |
| ) | |
| assert response.status_code == 503 | |
| # --------------------------------------------------------------------------- # | |
| # eventarc_gcs_upload_view | |
| # --------------------------------------------------------------------------- # | |
| def test_eventarc_method_not_allowed(client): | |
| response = client.get(reverse("eventarc-gcs-upload")) | |
| assert response.status_code == 405 | |
| def test_eventarc_invalid_json(client): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), data="{bad", content_type="application/json" | |
| ) | |
| assert response.status_code == 400 | |
| assert "Invalid JSON body" in response.json()["error"] | |
| def test_eventarc_missing_fields(client): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 400 | |
| assert "Missing bucket or name" in response.json()["error"] | |
| def test_eventarc_non_manga_no_enqueue(client): | |
| with patch.object(tv, "enqueue_task") as enqueue: | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "docs/readme.txt"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 200 | |
| body = response.json() | |
| assert body["status"] == "event processed" | |
| assert body["bucket"] == "b" | |
| enqueue.assert_not_called() | |
| def test_eventarc_manga_enqueues(client): | |
| with patch.object(tv, "enqueue_task") as enqueue: | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "raw-manga/page1.PNG"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.json()["status"] == "event processed" | |
| enqueue.assert_called_once_with( | |
| "process_gcs_upload_task", bucket="b", name="raw-manga/page1.PNG" | |
| ) | |
| def test_eventarc_manga_enqueue_failure(client): | |
| with patch.object(tv, "enqueue_task", side_effect=RuntimeError("queue down")): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "manga/p.jpeg"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 500 | |
| assert "Failed to enqueue task" in response.json()["error"] | |
| def test_eventarc_prod_missing_auth(client): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "n"}), | |
| content_type="application/json", | |
| ) | |
| assert response.status_code == 401 | |
| def test_eventarc_prod_bad_token(client): | |
| with patch( | |
| "animetix.tasks_views.id_token.verify_oauth2_token", | |
| side_effect=ValueError("bad"), | |
| ): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "n"}), | |
| content_type="application/json", | |
| HTTP_AUTHORIZATION="Bearer x", | |
| ) | |
| assert response.status_code == 403 | |
| assert response.json()["error"] == "Invalid OIDC token" | |
| def test_eventarc_prod_valid_token(client): | |
| with ( | |
| patch("animetix.tasks_views.id_token.verify_oauth2_token", return_value={}), | |
| patch.object(tv, "enqueue_task") as enqueue, | |
| ): | |
| response = client.post( | |
| reverse("eventarc-gcs-upload"), | |
| data=json.dumps({"bucket": "b", "name": "manga/p.webp"}), | |
| content_type="application/json", | |
| HTTP_AUTHORIZATION="Bearer good", | |
| ) | |
| assert response.status_code == 200 | |
| enqueue.assert_called_once() | |