Spaces:
Sleeping
Sleeping
| """Extra coverage tests for animetix.api.core targeting branches not covered by | |
| ``test_core_coverage.py``. | |
| Same pattern as the sibling file: neutralise permissions, drive views via | |
| ``RequestFactory`` + ``force_authenticate``, mock all I/O (services, adapters, | |
| httpx, ``safe_http_request``). No false-green: every test asserts the status | |
| code and, where meaningful, the JSON body / side effects. | |
| Targets the previously-missing line ranges in core.py: | |
| 93-96 (image_proxy generic exception -> 404) | |
| 152-197 (MediaSearchView.post image-search flow) | |
| 215-216 (GameSessionView.get) | |
| 288-289 (RegisterView create_user exception -> 400) | |
| 394-395 (TransparencyData positive_rate branch) | |
| 504-510 (suwayomi_image_proxy non-200 / exception) | |
| 552/583/652/696/703-705 (Suwayomi views: not-configured / exception) | |
| 732-775 (FavoriteMangaToggleView auto-import path) | |
| 910-1069 (MangaChapterSyncView: invalid chapter, anilist/MAL resolution+sync) | |
| """ | |
| import base64 | |
| import json | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from animetix.api.core import ( | |
| FavoriteMangaToggleView, | |
| GameSessionView, | |
| MangaChapterSyncView, | |
| MediaSearchView, | |
| RegisterView, | |
| SuwayomiExtensionsActionView, | |
| SuwayomiExtensionsListView, | |
| SuwayomiImportView, | |
| SuwayomiSearchView, | |
| TransparencyDataView, | |
| image_proxy_view, | |
| suwayomi_image_proxy, | |
| ) | |
| from animetix.containers import get_container | |
| from django.contrib.auth.models import User | |
| from django.test import RequestFactory | |
| from rest_framework.parsers import JSONParser, MultiPartParser | |
| from rest_framework.request import Request | |
| from rest_framework.test import force_authenticate | |
| def factory(): | |
| return RequestFactory() | |
| def _drive(view_cls, request, user=None, **view_kwargs): | |
| with patch.object(view_cls, "permission_classes", []): | |
| view = view_cls.as_view() | |
| if user is not None: | |
| force_authenticate(request, user=user) | |
| return view(request, **view_kwargs) | |
| # --------------------------------------------------------------------------- # | |
| # image_proxy_view: generic exception -> 404 (lines 93-96) | |
| # --------------------------------------------------------------------------- # | |
| def test_image_proxy_generic_exception_returns_404(factory): | |
| encoded = base64.b64encode(b"http://example.com/a.png").decode() | |
| request = factory.get("/img/", {"url": encoded, "sig": "good"}) | |
| with ( | |
| patch("animetix.api.core.media.verify_proxy_signature", return_value=True), | |
| patch("animetix.api.core.media.cache") as mock_cache, | |
| patch( | |
| "animetix.api.core.media.safe_http_request", | |
| side_effect=RuntimeError("network boom"), | |
| ), | |
| ): | |
| mock_cache.get.return_value = None | |
| response = image_proxy_view(request) | |
| assert response.status_code == 404 | |
| def test_image_proxy_non_200_upstream_returns_404(factory): | |
| """Upstream returns non-200; the success block is skipped -> falls through to 404.""" | |
| encoded = base64.b64encode(b"http://example.com/a.png").decode() | |
| request = factory.get("/img/", {"url": encoded, "sig": "good"}) | |
| upstream = MagicMock(status_code=500, content=b"", headers={}) | |
| with ( | |
| patch("animetix.api.core.media.verify_proxy_signature", return_value=True), | |
| patch("animetix.api.core.media.cache") as mock_cache, | |
| patch("animetix.api.core.media.safe_http_request", return_value=upstream), | |
| ): | |
| mock_cache.get.return_value = None | |
| response = image_proxy_view(request) | |
| assert response.status_code == 404 | |
| # --------------------------------------------------------------------------- # | |
| # MediaSearchView.post image-search flow (lines 152-197) | |
| # | |
| # `post()` takes `visual_index_service` as an `@inject`-ed keyword argument | |
| # (Provide[Container.core.visual_index_service]): passing an explicit mock for | |
| # that keyword overrides the DI default outright, so these tests never touch the | |
| # real, DI-wired singleton (whose `is_available()` would hit the empty test DB). | |
| # --------------------------------------------------------------------------- # | |
| def _post_image_view(request, *, quota=True, index_available=True, visual_index=None): | |
| view = MediaSearchView() | |
| view.guardrail_service = MagicMock() | |
| view.usage_port = MagicMock() | |
| view.usage_port.check_quota.return_value = quota | |
| visual = visual_index or MagicMock() | |
| visual.is_available.return_value = index_available | |
| drf_request = Request(request, parsers=[MultiPartParser(), JSONParser()]) | |
| return view, drf_request, visual | |
| def test_media_search_post_quota_exceeded(factory): | |
| user = User.objects.create_user(username="qe", password="pw") | |
| request = factory.post("/search/") | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request, quota=False) | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 403 | |
| assert "quota" in response.data["error"].lower() | |
| def test_media_search_post_no_image(factory): | |
| user = User.objects.create_user(username="ni", password="pw") | |
| request = factory.post("/search/") | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 400 | |
| assert response.data["error"] == "No image provided" | |
| def test_media_search_post_unknown_target(factory): | |
| """`target` is validated -- and charged nothing -- before the | |
| per-target availability guard even runs.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="banana", password="pw") | |
| img = SimpleUploadedFile("x.png", b"data", content_type="image/png") | |
| request = factory.post("/search/", {"image": img, "target": "banana"}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| with patch("animetix.api.core.media.deduct_berrix") as mock_deduct: | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 400 | |
| mock_deduct.assert_not_called() | |
| visual.is_available.assert_not_called() | |
| def test_media_search_post_image_too_large(factory): | |
| """An oversized upload must be rejected *before* Berrix is touched: the | |
| size is known without spending money, so charging first and validating | |
| after means paying for a request that was always going to be refused.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="big", password="pw") | |
| img = SimpleUploadedFile("x.png", b"data", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=False), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 413 | |
| mock_deduct.assert_not_called() | |
| def test_media_search_post_invalid_mime(factory): | |
| """A disallowed MIME type is likewise known without spending money: it | |
| must be rejected before Berrix is deducted, not after.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="badmime", password="pw") | |
| img = SimpleUploadedFile("x.png", b"data", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=False), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 415 | |
| mock_deduct.assert_not_called() | |
| def test_media_search_post_success(factory): | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="okimg", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| visual.encode_image.return_value = [0.1] * 512 | |
| visual.search.return_value = [{"id": "7", "title": "ByImage"}] | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 200 | |
| assert response.data[0]["id"] == "7" | |
| visual.encode_image.assert_called_once_with("work", b"imgbytes") | |
| visual.search.assert_called_once() | |
| view.usage_port.log_usage.assert_called_once() | |
| # Regression guard for the billing fix: when the index *is* available, | |
| # the search still charges exactly as before. | |
| mock_deduct.assert_called_once() | |
| def test_media_search_post_result_id_is_resolvable_by_media_detail_view(factory): | |
| """CRITICAL regression guard: every other test in this module hands | |
| `MediaSearchView.post` an already-mocked `visual_index_service`, so none | |
| of them exercise the REAL `VisualIndexService.search()` id-rewrite through | |
| the REAL `MediaItemSerializer`. Before the fix, the composite key the | |
| writer used internally (`vector_key`, "Anime:5114") travelled all the way | |
| to the client: `SearchBar.tsx` would navigate to | |
| `/media/Anime/Anime:5114/`, and `MediaDetailView` | |
| (`MediaItem.objects.get(media_type=..., external_id=item_id)`) would 404 | |
| on every single visual-search result -- a correct-looking hit that was | |
| always a dead link. | |
| """ | |
| from animetix.models import MediaItem | |
| from core.domain.services.visual_index import VisualIndexService | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| MediaItem.objects.create( | |
| external_id="5114", | |
| media_type="Anime", | |
| title="Fullmetal Alchemist", | |
| image_url="http://img/fma.png", | |
| ) | |
| user = User.objects.create_user(username="realhit", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| store = MagicMock() | |
| store.get_collection_count.return_value = 1 | |
| store.search_by_vector.return_value = [ | |
| { | |
| "id": "Anime:5114", | |
| "external_id": "5114", | |
| "media_type": "Anime", | |
| "title": "Fullmetal Alchemist", | |
| "image": "http://img/fma.png", | |
| } | |
| ] | |
| engine = MagicMock() | |
| engine.get_image_embedding.return_value = [0.1] * 512 | |
| real_visual = VisualIndexService( | |
| inference_engine=engine, repository=MagicMock(), vector_store=store | |
| ) | |
| view = MediaSearchView() | |
| view.guardrail_service = MagicMock() | |
| view.usage_port = MagicMock() | |
| view.usage_port.check_quota.return_value = True | |
| drf_request = Request(request, parsers=[MultiPartParser(), JSONParser()]) | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix"), | |
| ): | |
| response = view.post(drf_request, visual_index_service=real_visual) | |
| assert response.status_code == 200 | |
| result = response.data[0] | |
| # The id the client receives is the one `MediaDetailView` can actually | |
| # resolve -- not the internal composite key. | |
| assert result["id"] == "5114" | |
| assert ( | |
| MediaItem.objects.get(media_type=result["type"], external_id=result["id"]).title | |
| == "Fullmetal Alchemist" | |
| ) | |
| def test_media_search_post_index_unavailable_no_charge(factory): | |
| """An empty target index -- `work` or `character` -- must be refused up | |
| front (503, honest French copy) and Berrix must never be deducted.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="emptyidx", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request, index_available=False) | |
| with patch("animetix.api.core.media.deduct_berrix") as mock_deduct: | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 503 | |
| assert "index" in response.data["error"].lower() | |
| mock_deduct.assert_not_called() | |
| visual.encode_image.assert_not_called() | |
| visual.search.assert_not_called() | |
| def test_media_search_post_exception_returns_500(factory): | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="exc", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| with patch( | |
| "animetix.api.core.media.validate_file_size", | |
| side_effect=RuntimeError("boom"), | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 500 | |
| assert response.data["error"] == "Internal server error" | |
| def test_media_search_post_search_exception_returns_500(factory): | |
| """Distinct from `test_media_search_post_exception_returns_500` above, | |
| which now lands entirely inside the upload-validation try/except added | |
| by the billing fix: this covers the *other* try/except, the one around | |
| `encode_image`/`search` themselves, which nothing else exercises.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="searchexc", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| visual.encode_image.return_value = [0.1] * 512 | |
| visual.search.side_effect = RuntimeError("boom") | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix"), | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 500 | |
| assert response.data["error"] == "Internal server error" | |
| def test_media_search_post_encode_failure_never_charges_berrix(factory): | |
| """IMPORTANT: a cold Brain (Cloud Run GPU scaled to zero) is a routine | |
| event, not an outage. Before the fix, Berrix was deducted BEFORE | |
| encode_image/search ran, so a routine cold start meant the user paid Bx | |
| and read "Internal server error" for a search that never happened. Now | |
| the charge only happens after a successful encode+search: an encode | |
| failure must leave the user's balance untouched.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="nopay", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| visual.encode_image.side_effect = RuntimeError("brain unreachable: cold start") | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 500 | |
| mock_deduct.assert_not_called() | |
| view.usage_port.log_usage.assert_not_called() | |
| def test_media_search_post_search_failure_never_charges_berrix(factory): | |
| """Same guarantee, but the failure happens one step later (search itself, | |
| not encode) -- both must leave Berrix untouched.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="nopay2", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| visual.encode_image.return_value = [0.1] * 512 | |
| visual.search.side_effect = RuntimeError("pgvector connection lost") | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 500 | |
| mock_deduct.assert_not_called() | |
| def test_media_search_post_invalid_limit_is_a_400_not_a_500(factory): | |
| """MINOR: `int(request.data.get("limit", 10))` used to sit outside any | |
| try/except -- `limit=abc` was an uncaught ValueError -> 500. It must be a | |
| 400 (a client input error), and must never touch Berrix.""" | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="badlimit", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img, "limit": "abc"}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| view, drf_request, visual = _post_image_view(request) | |
| with patch("animetix.api.core.media.deduct_berrix") as mock_deduct: | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 400 | |
| mock_deduct.assert_not_called() | |
| visual.is_available.assert_not_called() | |
| def test_media_search_post_survives_cache_backend_failure(factory, mocker): | |
| """A Redis blip in the availability-check cache must not 500 an endpoint | |
| that doesn't otherwise need Redis -- it must fall back to a live COUNT | |
| and behave exactly as if the cache had a hit (index available -> search | |
| proceeds, charges, and returns 200).""" | |
| from adapters.persistence.pg_vector_store_adapter import PgVectorStoreAdapter | |
| from core.domain.services.visual_index import VisualIndexService | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| user = User.objects.create_user(username="cachefail", password="pw") | |
| img = SimpleUploadedFile("x.png", b"imgbytes", content_type="image/png") | |
| request = factory.post("/search/", {"image": img}) | |
| force_authenticate(request, user=user) | |
| request.user = user | |
| mock_manager = MagicMock() | |
| mock_manager.get_collection.return_value.count.return_value = 3 | |
| mocker.patch( | |
| "adapters.persistence.pg_vector_store_adapter.vector_manager", mock_manager | |
| ) | |
| mocker.patch( | |
| "adapters.persistence.pg_vector_store_adapter.cache.get", | |
| side_effect=RuntimeError("redis down"), | |
| ) | |
| real_vector_db = PgVectorStoreAdapter() | |
| visual = VisualIndexService( | |
| inference_engine=MagicMock(), | |
| repository=MagicMock(), | |
| vector_store=real_vector_db, | |
| ) | |
| visual.encode_image = MagicMock(return_value=[0.1] * 512) | |
| visual.search = MagicMock(return_value=[{"id": "1", "title": "T"}]) | |
| view = MediaSearchView() | |
| view.guardrail_service = MagicMock() | |
| view.usage_port = MagicMock() | |
| view.usage_port.check_quota.return_value = True | |
| drf_request = Request(request, parsers=[MultiPartParser(), JSONParser()]) | |
| with ( | |
| patch("animetix.api.core.media.validate_file_size", return_value=True), | |
| patch("animetix.api.core.media.validate_file_mime_type", return_value=True), | |
| patch("animetix.api.core.media.deduct_berrix") as mock_deduct, | |
| ): | |
| response = view.post(drf_request, visual_index_service=visual) | |
| assert response.status_code == 200 | |
| mock_manager.get_collection.assert_called() | |
| mock_deduct.assert_called_once() | |
| # --------------------------------------------------------------------------- # | |
| # GameSessionView.get (lines 215-216) | |
| # --------------------------------------------------------------------------- # | |
| def test_game_session_get(factory): | |
| request = factory.get("/game/session/") | |
| fake_session = { | |
| "media_type": "Anime", | |
| "is_ranked": True, | |
| "is_daily": False, | |
| "game_over": False, | |
| "guesses": ["a", "b"], | |
| } | |
| with patch( | |
| "animetix.api.core.accounts.get_session_service", return_value=fake_session | |
| ): | |
| response = _drive(GameSessionView, request) | |
| assert response.status_code == 200 | |
| assert response.data["media_type"] == "Anime" | |
| assert response.data["guess_count"] == 2 | |
| # --------------------------------------------------------------------------- # | |
| # RegisterView create_user exception -> 400 (lines 288-289) | |
| # --------------------------------------------------------------------------- # | |
| def test_register_create_user_exception(factory): | |
| request = factory.post( | |
| "/auth/register/", | |
| {"username": "newx", "password": "pw12345", "email": "n@x.com"}, | |
| ) | |
| with patch( | |
| "animetix.api.core.accounts.User.objects.create_user", | |
| side_effect=RuntimeError("db down"), | |
| ): | |
| response = _drive(RegisterView, request) | |
| assert response.status_code == 400 | |
| assert "db down" in response.data["error"] | |
| # --------------------------------------------------------------------------- # | |
| # TransparencyData positive_rate branch (lines 394-395) | |
| # --------------------------------------------------------------------------- # | |
| def test_transparency_with_feedback(factory): | |
| from animetix.models import AIFeedback | |
| # Two feedbacks, one positive -> positive_rate computed branch executed. | |
| AIFeedback.objects.create(is_positive=True) | |
| AIFeedback.objects.create(is_positive=False) | |
| request = factory.get("/transparency/") | |
| mock_sota = MagicMock() | |
| mock_sota.get_all_benchmarks.return_value = [] | |
| mock_drift = MagicMock() | |
| mock_drift.get_drift_report.return_value = {} | |
| real_container = get_container() | |
| real_container.core.sota_benchmark_service.override(mock_sota) | |
| real_container.core.drift_service.override(mock_drift) | |
| try: | |
| response = _drive(TransparencyDataView, request) | |
| finally: | |
| real_container.core.sota_benchmark_service.reset_last_overriding() | |
| real_container.core.drift_service.reset_last_overriding() | |
| assert response.status_code == 200 | |
| assert response.data["global_metrics"]["total_feedbacks"] == 2 | |
| assert response.data["global_metrics"]["community_satisfaction"] == 0.5 | |
| # --------------------------------------------------------------------------- # | |
| # suwayomi_image_proxy non-200 / exception (lines 504-510) | |
| # --------------------------------------------------------------------------- # | |
| def test_suwayomi_image_non_200(factory): | |
| encoded = base64.b64encode(b"relative/page.png").decode() | |
| request = factory.get("/suwayomi-image/", {"page_url": encoded}) | |
| upstream = MagicMock(status_code=404, content=b"", headers={}) | |
| with ( | |
| patch("core.config.settings") as mock_settings, | |
| patch("animetix.api.core.suwayomi.safe_http_request", return_value=upstream), | |
| ): | |
| mock_settings.SUWAYOMI_URL = "http://suwayomi.local" | |
| mock_settings.SUWAYOMI_PASSWORD = "" | |
| response = suwayomi_image_proxy(request) | |
| assert response.status_code == 404 | |
| def test_suwayomi_image_exception(factory): | |
| encoded = base64.b64encode(b"relative/page.png").decode() | |
| request = factory.get("/suwayomi-image/", {"page_url": encoded}) | |
| with ( | |
| patch("core.config.settings") as mock_settings, | |
| patch( | |
| "animetix.api.core.suwayomi.safe_http_request", | |
| side_effect=RuntimeError("conn refused"), | |
| ), | |
| ): | |
| mock_settings.SUWAYOMI_URL = "http://suwayomi.local" | |
| mock_settings.SUWAYOMI_PASSWORD = "" | |
| response = suwayomi_image_proxy(request) | |
| assert response.status_code == 500 | |
| def test_suwayomi_image_absolute_authorized_url(factory): | |
| """Absolute URL that matches the configured base is allowed through.""" | |
| encoded = base64.b64encode(b"http://suwayomi.local/page.png").decode() | |
| request = factory.get("/suwayomi-image/", {"page_url": encoded}) | |
| upstream = MagicMock( | |
| status_code=200, content=b"PNG", headers={"Content-Type": "image/png"} | |
| ) | |
| with ( | |
| patch("core.config.settings") as mock_settings, | |
| patch("animetix.api.core.suwayomi.safe_http_request", return_value=upstream), | |
| ): | |
| mock_settings.SUWAYOMI_URL = "http://suwayomi.local" | |
| mock_settings.SUWAYOMI_PASSWORD = "pw" | |
| response = suwayomi_image_proxy(request) | |
| assert response.status_code == 200 | |
| # --------------------------------------------------------------------------- # | |
| # Suwayomi views: not-configured branches (552, 583, 652, 696) | |
| # --------------------------------------------------------------------------- # | |
| def test_suwayomi_search_not_configured(factory): | |
| request = factory.get("/suwayomi/search/", {"source_id": "s1"}) | |
| view = SuwayomiSearchView() | |
| view.suwayomi_adapter = None | |
| response = view.get(Request(request)) | |
| assert response.status_code == 500 | |
| def test_suwayomi_import_not_configured(factory): | |
| request = factory.post( | |
| "/import/", | |
| json.dumps({"source_id": "s", "suwayomi_manga_id": "m"}), | |
| content_type="application/json", | |
| ) | |
| view = SuwayomiImportView() | |
| view.suwayomi_adapter = None | |
| response = view.post(Request(request, parsers=[JSONParser()])) | |
| assert response.status_code == 500 | |
| def test_suwayomi_extensions_list_not_configured(factory): | |
| request = factory.get("/extensions/") | |
| view = SuwayomiExtensionsListView() | |
| view.suwayomi_adapter = None | |
| response = view.get(Request(request)) | |
| assert response.status_code == 500 | |
| def test_suwayomi_extensions_action_not_configured(factory): | |
| request = factory.post( | |
| "/extensions/action/", | |
| json.dumps({"ids": ["1"], "action": "install"}), | |
| content_type="application/json", | |
| ) | |
| view = SuwayomiExtensionsActionView() | |
| view.suwayomi_adapter = None | |
| response = view.post(Request(request, parsers=[JSONParser()])) | |
| assert response.status_code == 500 | |
| def test_suwayomi_extensions_action_exception(factory): | |
| """update_extensions raising -> 500 (lines 703-705).""" | |
| request = factory.post( | |
| "/extensions/action/", | |
| json.dumps({"ids": ["1"], "action": "update"}), | |
| content_type="application/json", | |
| ) | |
| view = SuwayomiExtensionsActionView() | |
| view.suwayomi_adapter = MagicMock() | |
| view.suwayomi_adapter.update_extensions.side_effect = RuntimeError("boom") | |
| response = view.post(Request(request, parsers=[JSONParser()])) | |
| assert response.status_code == 500 | |
| assert response.data["error"] == "Internal server error" | |
| # --------------------------------------------------------------------------- # | |
| # FavoriteMangaToggleView auto-import path (lines 732-775) | |
| # --------------------------------------------------------------------------- # | |
| def test_favorite_toggle_autoimport_not_configured(factory): | |
| user = User.objects.create_user(username="fa1", password="pw") | |
| request = factory.post( | |
| "/media/Manga/new-x/favorite/", | |
| json.dumps({"source_id": "s", "suwayomi_manga_id": "m"}), | |
| content_type="application/json", | |
| ) | |
| real_container = get_container() | |
| real_container.persistence.suwayomi_adapter.override(None) | |
| try: | |
| force_authenticate(request, user=user) | |
| with patch.object(FavoriteMangaToggleView, "permission_classes", []): | |
| view = FavoriteMangaToggleView.as_view() | |
| response = view(request, media_id="new-x") | |
| finally: | |
| real_container.persistence.suwayomi_adapter.reset_last_overriding() | |
| assert response.status_code == 500 | |
| def test_favorite_toggle_autoimport_details_missing(factory): | |
| user = User.objects.create_user(username="fa2", password="pw") | |
| request = factory.post( | |
| "/media/Manga/new-y/favorite/", | |
| json.dumps({"source_id": "s", "suwayomi_manga_id": "m"}), | |
| content_type="application/json", | |
| ) | |
| adapter = MagicMock() | |
| adapter.get_manga_details.return_value = None | |
| real_container = get_container() | |
| real_container.persistence.suwayomi_adapter.override(adapter) | |
| try: | |
| force_authenticate(request, user=user) | |
| with patch.object(FavoriteMangaToggleView, "permission_classes", []): | |
| view = FavoriteMangaToggleView.as_view() | |
| response = view(request, media_id="new-y") | |
| finally: | |
| real_container.persistence.suwayomi_adapter.reset_last_overriding() | |
| assert response.status_code == 404 | |
| def test_favorite_toggle_autoimport_success(factory): | |
| from animetix.models import FavoriteManga, MediaItem | |
| user = User.objects.create_user(username="fa3", password="pw") | |
| request = factory.post( | |
| "/media/Manga/new-z/favorite/", | |
| json.dumps({"source_id": "s", "suwayomi_manga_id": "m"}), | |
| content_type="application/json", | |
| ) | |
| adapter = MagicMock() | |
| adapter.get_manga_details.return_value = { | |
| "title": "Auto Imported", | |
| "description": "d", | |
| "thumbnailUrl": "http://thumb/y.png", | |
| "author": "A", | |
| "artist": "B", | |
| "status": "ongoing", | |
| } | |
| mock_manga_service = MagicMock() | |
| real_container = get_container() | |
| real_container.persistence.suwayomi_adapter.override(adapter) | |
| real_container.core.manga_service.override(mock_manga_service) | |
| try: | |
| force_authenticate(request, user=user) | |
| with patch.object(FavoriteMangaToggleView, "permission_classes", []): | |
| view = FavoriteMangaToggleView.as_view() | |
| response = view(request, media_id="new-z") | |
| finally: | |
| real_container.persistence.suwayomi_adapter.reset_last_overriding() | |
| real_container.core.manga_service.reset_last_overriding() | |
| assert response.status_code == 200 | |
| assert response.data["is_favorite"] is True | |
| # Manga was auto-created and favorited. | |
| assert MediaItem.objects.filter(external_id="new-z").exists() | |
| assert FavoriteManga.objects.filter(user=user).count() == 1 | |
| # --------------------------------------------------------------------------- # | |
| # MangaChapterSyncView branches (910-1069) | |
| # --------------------------------------------------------------------------- # | |
| def test_chapter_sync_invalid_chapter_number(factory): | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv1", password="pw") | |
| MediaItem.objects.create(external_id="man-iv", media_type="Manga", title="IV") | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="mock-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-iv", | |
| chapter_number="abc", | |
| ) | |
| assert response.status_code == 400 | |
| assert "Invalid chapter" in response.data["error"] | |
| def test_chapter_sync_anilist_resolve_by_title(factory): | |
| """Non-numeric id, no metadata id -> resolves AniList id via GraphQL search, | |
| then performs a real (mocked) mutation request.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv2", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-al", media_type="Manga", title="AniManga", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| search_resp = MagicMock(status_code=200) | |
| search_resp.json.return_value = {"data": {"Media": {"id": 555}}} | |
| mutation_resp = MagicMock(status_code=200) | |
| mutation_resp.json.return_value = {"data": {}} | |
| client = MagicMock() | |
| client.post.side_effect = [search_resp, mutation_resp] | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-al", | |
| chapter_number="4", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"] == {"success": True} | |
| def test_chapter_sync_anilist_unresolvable(factory): | |
| """Title search returns no Media -> anilist id unresolved -> error result.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv3", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-no", media_type="Manga", title="Unknown", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| search_resp = MagicMock(status_code=200) | |
| search_resp.json.return_value = {"data": {"Media": None}} | |
| client = MagicMock() | |
| client.post.return_value = search_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-no", | |
| chapter_number="2", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"]["success"] is False | |
| assert "resolve" in response.data["results"]["anilist"]["error"].lower() | |
| def test_chapter_sync_anilist_real_mutation_error(factory): | |
| """metadata id resolves anilist id; mutation returns non-200 -> error result.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv4", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-meta", | |
| media_type="Manga", | |
| title="Meta", | |
| metadata={"id": "999"}, | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| mutation_resp = MagicMock(status_code=400, text="bad request") | |
| client = MagicMock() | |
| client.post.return_value = mutation_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-meta", | |
| chapter_number="6", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"]["success"] is False | |
| assert "AniList API error" in response.data["results"]["anilist"]["error"] | |
| def test_chapter_sync_mal_resolve_via_jikan_and_real_update(factory): | |
| """No mal id in metadata -> resolve via Jikan, then real (mocked) PATCH.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv5", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-mal", media_type="Manga", title="MalT", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="myanimelist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| jikan_resp = MagicMock(status_code=200) | |
| jikan_resp.json.return_value = {"data": [{"mal_id": 321}]} | |
| patch_resp = MagicMock(status_code=200) | |
| client = MagicMock() | |
| client.get.return_value = jikan_resp | |
| client.patch.return_value = patch_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-mal", | |
| chapter_number="8", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["myanimelist"] == {"success": True} | |
| def test_chapter_sync_mal_unresolvable(factory): | |
| """Jikan returns empty -> mal id unresolved -> error result.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv6", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-mn", media_type="Manga", title="NoMal", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="myanimelist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| jikan_resp = MagicMock(status_code=200) | |
| jikan_resp.json.return_value = {"data": []} | |
| client = MagicMock() | |
| client.get.return_value = jikan_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-mn", | |
| chapter_number="3", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["myanimelist"]["success"] is False | |
| assert "resolve" in response.data["results"]["myanimelist"]["error"].lower() | |
| def test_chapter_sync_anilist_metadata_id_non_int(factory): | |
| """metadata['id'] is non-numeric -> int() raises -> swallowed (929-930); | |
| falls back to title search which also yields nothing -> unresolved.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv8", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-bad", | |
| media_type="Manga", | |
| title="BadMeta", | |
| metadata={"id": "not-a-number"}, | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| search_resp = MagicMock(status_code=200) | |
| search_resp.json.return_value = {"data": {"Media": None}} | |
| client = MagicMock() | |
| client.post.return_value = search_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-bad", | |
| chapter_number="1", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"]["success"] is False | |
| def test_chapter_sync_anilist_search_raises(factory): | |
| """httpx.Client raising during AniList title search -> except logged (955-956), | |
| id stays unresolved -> error result.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv9", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-sx", media_type="Manga", title="SearchX", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| with patch("httpx.Client", side_effect=RuntimeError("net down")): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-sx", | |
| chapter_number="1", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"]["success"] is False | |
| def test_chapter_sync_anilist_mutation_raises(factory): | |
| """metadata id resolves anilist id; mutation client raises -> except (1005-1006).""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv10", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-mx", | |
| media_type="Manga", | |
| title="MutX", | |
| metadata={"id": "42"}, | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="anilist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| with patch("httpx.Client", side_effect=RuntimeError("mutation boom")): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-mx", | |
| chapter_number="2", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["anilist"]["success"] is False | |
| assert "mutation boom" in response.data["results"]["anilist"]["error"] | |
| def test_chapter_sync_mal_jikan_raises(factory): | |
| """Jikan client raises -> except logged (1032-1033), mal id unresolved -> error.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv11", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-jx", media_type="Manga", title="JikanX", metadata={} | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="myanimelist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| with patch("httpx.Client", side_effect=RuntimeError("jikan down")): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-jx", | |
| chapter_number="2", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["myanimelist"]["success"] is False | |
| assert "resolve" in response.data["results"]["myanimelist"]["error"].lower() | |
| def test_chapter_sync_mal_update_raises(factory): | |
| """mal id from metadata; PATCH client raises -> except (1068-1069).""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv12", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-ux", | |
| media_type="Manga", | |
| title="UpdX", | |
| metadata={"idMal": 55}, | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="myanimelist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| with patch("httpx.Client", side_effect=RuntimeError("patch boom")): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-ux", | |
| chapter_number="3", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["myanimelist"]["success"] is False | |
| assert "patch boom" in response.data["results"]["myanimelist"]["error"] | |
| def test_chapter_sync_mal_real_update_error(factory): | |
| """mal_id present in metadata; PATCH returns non-200 -> error result.""" | |
| from animetix.models import MediaItem, TrackerConnection | |
| user = User.objects.create_user(username="sv7", password="pw") | |
| MediaItem.objects.create( | |
| external_id="man-me", | |
| media_type="Manga", | |
| title="MalErr", | |
| metadata={"mal_id": 77}, | |
| ) | |
| TrackerConnection.objects.create( | |
| user=user, tracker="myanimelist", token="real-token", username="u" | |
| ) | |
| request = factory.post("/sync/", {}, content_type="application/json") | |
| patch_resp = MagicMock(status_code=403, text="forbidden") | |
| client = MagicMock() | |
| client.patch.return_value = patch_resp | |
| client_cm = MagicMock() | |
| client_cm.__enter__.return_value = client | |
| with patch("httpx.Client", return_value=client_cm): | |
| response = _drive( | |
| MangaChapterSyncView, | |
| request, | |
| user=user, | |
| media_id="man-me", | |
| chapter_number="9", | |
| ) | |
| assert response.status_code == 200 | |
| assert response.data["results"]["myanimelist"]["success"] is False | |
| assert "MAL API error" in response.data["results"]["myanimelist"]["error"] | |