import pytest from animetix.models import MangaChapter, MangaPage, MangaReadingProgress, MediaItem from django.contrib.auth.models import User from django.urls import reverse from rest_framework.test import APIClient @pytest.fixture def reader(db): client = APIClient() user = User.objects.create_user(username="progress_reader", password="pw") client.force_authenticate(user=user) manga = MediaItem.objects.create( external_id="suwayomi:1:809", media_type="Manga", title="OPM" ) chapter = MangaChapter.objects.create(manga=manga, number=164.2, external_id="7") for i in range(1, 4): MangaPage.objects.create( chapter=chapter, number=i, image_url=f"http://h/{i}.jpg" ) return client, user, manga, chapter @pytest.mark.django_db def test_progress_requires_authentication(api_client): url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) assert api_client.get(url).status_code == 403 @pytest.mark.django_db def test_progress_returns_204_for_a_manga_absent_from_the_catalog(reader): client, *_ = reader url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:unknown"}) assert client.get(url).status_code == 204 @pytest.mark.django_db def test_put_then_get_progress_roundtrip(reader): client, user, _manga, chapter = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) res = client.put(put_url, {"last_page_read": 1, "is_read": False}, format="json") assert res.status_code == 200 assert res.data["last_page_read"] == 1 get_url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) payload = client.get(get_url).data assert payload["chapters"][0] == { "number": 164.2, "is_read": False, "is_bookmarked": False, "last_page_read": 1, "page_count": 3, } assert payload["resume"] == {"chapter_number": 164.2, "last_page_read": 1} assert MangaReadingProgress.objects.filter(user=user, chapter=chapter).count() == 1 @pytest.mark.django_db def test_decimal_chapter_numbers_are_preserved(reader): client, _user, _manga, _chapter = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) res = client.put(put_url, {"last_page_read": 2, "is_read": False}, format="json") assert res.data["number"] == 164.2 @pytest.mark.django_db def test_put_on_unknown_chapter_returns_404(reader): client, *_ = reader url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "999"}, ) assert ( client.put( url, {"last_page_read": 0, "is_read": False}, format="json" ).status_code == 404 ) @pytest.mark.django_db def test_mark_read_in_bulk(reader): client, _user, manga, _chapter = reader MangaChapter.objects.create(manga=manga, number=165.0, external_id="8") url = reverse("api_manga_progress_mark_read", kwargs={"media_id": "suwayomi:1:809"}) res = client.post( url, {"chapter_numbers": [164.2, 165.0], "is_read": True}, format="json" ) assert res.status_code == 200 assert res.data["updated"] == 2 @pytest.mark.django_db def test_chapter_completed_pushes_to_trackers_once_on_transition(reader, mocker): # Mocked where it is *used* (animetix.api.core.manga), not where it is # defined: the view imports it at module level, so patching there is what # actually intercepts the call. push_mock = mocker.patch( "animetix.api.core.manga.progress.push_manga_progress_to_trackers" ) client, *_ = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) first = client.put(put_url, {"last_page_read": 2, "is_read": True}, format="json") assert first.status_code == 200 assert push_mock.call_count == 1 # Same chapter, already read: chapter_completed is only True on the # unread->read transition, so a repeat PUT must NOT push again. second = client.put(put_url, {"last_page_read": 2, "is_read": True}, format="json") assert second.status_code == 200 assert push_mock.call_count == 1 @pytest.mark.django_db def test_tracker_push_failure_does_not_break_the_response(reader, mocker): mocker.patch( "animetix.api.core.manga.progress.push_manga_progress_to_trackers", side_effect=RuntimeError("tracker down"), ) client, user, _manga, chapter = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) res = client.put(put_url, {"last_page_read": 2, "is_read": True}, format="json") assert res.status_code == 200 assert ( MangaReadingProgress.objects.get(user=user, chapter=chapter).last_page_read == 2 ) @pytest.mark.django_db def test_progress_is_isolated_between_users(reader): client, _user, _manga, _chapter = reader other_client = APIClient() other_user = User.objects.create_user(username="other_reader", password="pw") other_client.force_authenticate(user=other_user) put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) client.put(put_url, {"last_page_read": 3, "is_read": False}, format="json") get_url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) other_payload = other_client.get(get_url).data # The second user has never read anything: no last_page_read leaked from # the first user's write. assert other_payload["chapters"][0]["last_page_read"] == 0 assert other_payload["resume"] is None # The second user's own write must not clobber the first user's progress. other_client.put(put_url, {"last_page_read": 1, "is_read": False}, format="json") first_payload = client.get(get_url).data assert first_payload["chapters"][0]["last_page_read"] == 3 @pytest.mark.django_db def test_put_with_negative_last_page_read_returns_400(reader): client, *_ = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) res = client.put(put_url, {"last_page_read": -1, "is_read": False}, format="json") assert res.status_code == 400 @pytest.mark.django_db def test_put_with_non_numeric_chapter_number_returns_400(reader): client, *_ = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "not-a-number"}, ) res = client.put(put_url, {"last_page_read": 0, "is_read": False}, format="json") assert res.status_code == 400 @pytest.mark.django_db def test_mark_read_with_non_numeric_chapter_number_returns_400(reader): client, *_ = reader url = reverse("api_manga_progress_mark_read", kwargs={"media_id": "suwayomi:1:809"}) res = client.post( url, {"chapter_numbers": [164.2, "not-a-number"], "is_read": True}, format="json", ) assert res.status_code == 400 @pytest.mark.django_db def test_string_false_is_not_read_as_true(reader): """`bool("false")` vaut True : un corps form-encoded inversait la demande.""" client, _user, _manga, _chapter = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) res = client.put(put_url, {"last_page_read": 1, "is_read": "false"}, format="json") assert res.status_code == 200 assert res.data["is_read"] is False res = client.put(put_url, {"last_page_read": 2, "is_read": "true"}, format="json") assert res.data["is_read"] is True @pytest.mark.django_db def test_progress_payload_carries_the_bookmark_flag(reader): client, user, _manga, chapter = reader MangaReadingProgress.objects.create(user=user, chapter=chapter, is_bookmarked=True) url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) rows = client.get(url).json()["chapters"] assert [row["is_bookmarked"] for row in rows] == [True] @pytest.mark.django_db def test_a_chapter_without_progress_is_not_bookmarked(reader): """Le drapeau doit valoir `False` — pas être absent — pour un chapitre jamais touché : le client filtre dessus sans avoir à distinguer les deux cas.""" client, *_ = reader url = reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) rows = client.get(url).json()["chapters"] assert rows[0]["is_bookmarked"] is False @pytest.mark.django_db def test_mark_read_string_false_unmarks(reader): """Le seul chemin de remise à zéro ne doit pas se faire retourner par "false".""" client, _user, _manga, _chapter = reader put_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) client.put(put_url, {"last_page_read": 2, "is_read": True}, format="json") mark_url = reverse( "api_manga_progress_mark_read", kwargs={"media_id": "suwayomi:1:809"} ) res = client.post( mark_url, {"chapter_numbers": [164.2], "is_read": "false"}, format="json" ) assert res.status_code == 200 payload = client.get( reverse("api_manga_progress", kwargs={"media_id": "suwayomi:1:809"}) ).data assert payload["chapters"][0]["is_read"] is False assert payload["chapters"][0]["last_page_read"] == 0 def _bookmark_url(number: str = "164.2") -> str: return reverse( "api_manga_chapter_bookmark", kwargs={"media_id": "suwayomi:1:809", "chapter_number": number}, ) @pytest.mark.django_db def test_bookmark_toggles_on_and_off(reader): client, user, _manga, chapter = reader assert ( client.put(_bookmark_url(), {"is_bookmarked": True}, format="json").status_code == 200 ) assert ( MangaReadingProgress.objects.get(user=user, chapter=chapter).is_bookmarked is True ) assert ( client.put(_bookmark_url(), {"is_bookmarked": False}, format="json").status_code == 200 ) assert ( MangaReadingProgress.objects.get(user=user, chapter=chapter).is_bookmarked is False ) @pytest.mark.django_db def test_bookmarking_a_never_opened_chapter_creates_its_progress_row(reader): """Marquer un chapitre qu'on n'a jamais ouvert est légitime : la ligne de progression est créée, avec son curseur à zéro.""" client, user, _manga, chapter = reader client.put(_bookmark_url(), {"is_bookmarked": True}, format="json") row = MangaReadingProgress.objects.get(user=user, chapter=chapter) assert row.is_bookmarked is True assert row.last_page_read == 0 assert row.is_read is False # Les deux tests suivants sont le cœur de la séparation des deux routes. Ils # gardent une régression FUTURE, pas un bug actuel : `update_or_create` préserve # aujourd'hui les champs qu'il ne nomme pas. Ils rougiraient si quelqu'un # ajoutait `is_bookmarked` aux `defaults` du `PUT` de progression, ou # `last_page_read` à ceux de la bascule. @pytest.mark.django_db def test_turning_a_page_does_not_erase_a_bookmark(reader): client, user, _manga, chapter = reader client.put(_bookmark_url(), {"is_bookmarked": True}, format="json") progress_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) client.put(progress_url, {"last_page_read": 2, "is_read": False}, format="json") row = MangaReadingProgress.objects.get(user=user, chapter=chapter) assert row.is_bookmarked is True assert row.last_page_read == 2 @pytest.mark.django_db def test_bookmarking_does_not_reset_the_page_cursor(reader): client, user, _manga, chapter = reader progress_url = reverse( "api_manga_chapter_progress", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) client.put(progress_url, {"last_page_read": 2, "is_read": True}, format="json") client.put(_bookmark_url(), {"is_bookmarked": True}, format="json") row = MangaReadingProgress.objects.get(user=user, chapter=chapter) assert row.last_page_read == 2 assert row.is_read is True @pytest.mark.django_db def test_marking_a_chapter_read_does_not_erase_its_bookmark(reader): """Le troisième écrivain de la ligne, gardé lui aussi. `POST progress/mark-read/` touche la même ligne que la progression et le signet, et il est monté dans le composant qui porte l'étoile : ajouter `is_bookmarked` à son `bulk_update` (ou le forcer à `False`) effacerait le signet sans qu'aucun autre test ne bronche. """ client, user, _manga, chapter = reader client.put(_bookmark_url(), {"is_bookmarked": True}, format="json") mark_read_url = reverse( "api_manga_progress_mark_read", kwargs={"media_id": "suwayomi:1:809"} ) client.post( mark_read_url, {"chapter_numbers": [164.2], "is_read": True}, format="json", ) row = MangaReadingProgress.objects.get(user=user, chapter=chapter) assert row.is_read is True assert row.is_bookmarked is True # Et la remise à zéro non plus : elle repart de la page 0, pas du signet. client.post( mark_read_url, {"chapter_numbers": [164.2], "is_read": False}, format="json", ) row.refresh_from_db() assert row.is_read is False assert row.is_bookmarked is True @pytest.mark.django_db def test_bookmark_rejects_an_unknown_chapter(reader): client, *_ = reader res = client.put(_bookmark_url("999"), {"is_bookmarked": True}, format="json") assert res.status_code == 404 # Même corps que la route sœur de progression, pas un 404 muet. assert res.data == {"error": "Chapter not found"} @pytest.mark.django_db def test_bookmark_requires_authentication(api_client): url = reverse( "api_manga_chapter_bookmark", kwargs={"media_id": "suwayomi:1:809", "chapter_number": "164.2"}, ) assert ( api_client.put(url, {"is_bookmarked": True}, format="json").status_code == 403 )