Spaces:
Sleeping
Sleeping
| """Coverage-focused tests for ``animetix.api.social`` DRF views. | |
| These hit the real URL routing + ORM (``django_db``) so the heavy | |
| club/feed/friend model interactions are genuinely exercised. External | |
| side-effects (the club-event task queue) are mocked. The throttle cache is | |
| forced to local memory because CI sets REDIS_URL, which would otherwise make | |
| DRF throttling connect to a real redis instance. | |
| """ | |
| from unittest.mock import patch | |
| import pytest | |
| from animetix.models import ( | |
| Achievement, | |
| ClubEvent, | |
| ClubMembership, | |
| CreativeFusion, | |
| DiscoveryClub, | |
| Friendship, | |
| GameplaySession, | |
| Notification, | |
| UserAchievement, | |
| ) | |
| from django.contrib.auth.models import User | |
| from django.test import override_settings | |
| from django.urls import reverse | |
| from django.utils import timezone | |
| from rest_framework.test import APIClient | |
| LOCMEM_CACHE = { | |
| "default": { | |
| "BACKEND": "django.core.cache.backends.locmem.LocMemCache", | |
| "LOCATION": "social-coverage", | |
| } | |
| } | |
| pytestmark = pytest.mark.django_db | |
| def _locmem_cache(): | |
| """Force LocMem cache so DRF throttling does not hit redis (CI sets REDIS_URL).""" | |
| with override_settings(CACHES=LOCMEM_CACHE): | |
| yield | |
| def api_client(): | |
| return APIClient() | |
| def user(db): | |
| return User.objects.create_user(username="alice", password="password") | |
| def other_user(db): | |
| return User.objects.create_user(username="bob", password="password") | |
| def auth_client(api_client, user): | |
| api_client.force_authenticate(user=user) | |
| return api_client | |
| def _make_premium(u): | |
| u.profile.tier = "premium" | |
| u.profile.save() | |
| # ========================================================================== | |
| # ClubViewSet | |
| # ========================================================================== | |
| def test_club_list_only_public_and_member(auth_client, user, other_user): | |
| public = DiscoveryClub.objects.create( | |
| name="Public", description="d", creator=other_user, is_private=False | |
| ) | |
| DiscoveryClub.objects.create( | |
| name="SecretBob", description="d", creator=other_user, is_private=True | |
| ) | |
| mine = DiscoveryClub.objects.create( | |
| name="MinePrivate", description="d", creator=user, is_private=True | |
| ) | |
| resp = auth_client.get(reverse("api-clubs")) | |
| assert resp.status_code == 200 | |
| names = ( | |
| {c["name"] for c in resp.data["results"]} | |
| if isinstance(resp.data, dict) | |
| else {c["name"] for c in resp.data} | |
| ) | |
| assert public.name in names | |
| assert mine.name in names | |
| assert "SecretBob" not in names | |
| def test_club_create_requires_premium(auth_client): | |
| resp = auth_client.post( | |
| reverse("api-clubs"), {"name": "FreeClub", "description": "x"}, format="json" | |
| ) | |
| assert resp.status_code == 400 # ValidationError -> premium only | |
| def test_club_create_premium_success_makes_officer(auth_client, user): | |
| _make_premium(user) | |
| resp = auth_client.post( | |
| reverse("api-clubs"), {"name": "ProClub", "description": "x"}, format="json" | |
| ) | |
| assert resp.status_code == 201 | |
| club = DiscoveryClub.objects.get(name="ProClub") | |
| assert ClubMembership.objects.get(user=user, club=club).role == "Officer" | |
| def test_club_join_success(auth_client, user, other_user): | |
| club = DiscoveryClub.objects.create( | |
| name="JoinMe", description="d", creator=other_user | |
| ) | |
| resp = auth_client.post(reverse("api-club-join", args=[club.id])) | |
| assert resp.status_code == 200 | |
| assert resp.data["status"] == "joined" | |
| assert ClubMembership.objects.filter(user=user, club=club).exists() | |
| def test_club_join_already_member(auth_client, user, other_user): | |
| club = DiscoveryClub.objects.create(name="Dup", description="d", creator=other_user) | |
| ClubMembership.objects.create(user=user, club=club) | |
| resp = auth_client.post(reverse("api-club-join", args=[club.id])) | |
| assert resp.status_code == 400 | |
| assert resp.data["status"] == "already member" | |
| def test_club_join_free_limit(auth_client, user, other_user): | |
| # free tier capped at 3 clubs | |
| for i in range(3): | |
| c = DiscoveryClub.objects.create( | |
| name=f"C{i}", description="d", creator=other_user | |
| ) | |
| ClubMembership.objects.create(user=user, club=c) | |
| target = DiscoveryClub.objects.create( | |
| name="Fourth", description="d", creator=other_user | |
| ) | |
| resp = auth_client.post(reverse("api-club-join", args=[target.id])) | |
| assert resp.status_code == 400 | |
| assert "Limite" in resp.data["error"] | |
| def test_club_leave(auth_client, user, other_user): | |
| club = DiscoveryClub.objects.create( | |
| name="LeaveMe", description="d", creator=other_user | |
| ) | |
| ClubMembership.objects.create(user=user, club=club) | |
| resp = auth_client.post(reverse("api-club-leave", args=[club.id])) | |
| assert resp.status_code == 200 | |
| assert resp.data["status"] == "left" | |
| assert not ClubMembership.objects.filter(user=user, club=club).exists() | |
| # ``trigger_event`` has no URL route registered, so we dispatch the viewset | |
| # action directly via APIRequestFactory (real view, real ORM, real permissions). | |
| def _dispatch_club_action(action_map, user, club_id, data=None): | |
| from animetix.api.social import ClubViewSet | |
| from rest_framework.test import APIRequestFactory, force_authenticate | |
| factory = APIRequestFactory() | |
| request = factory.post("/", data or {}, format="json") | |
| force_authenticate(request, user=user) | |
| view = ClubViewSet.as_view(action_map) | |
| return view(request, pk=club_id) | |
| def test_club_trigger_event_officer(user): | |
| club = DiscoveryClub.objects.create(name="TrigClub", description="d", creator=user) | |
| ClubMembership.objects.create(user=user, club=club, role="Officer") | |
| with patch("animetix.tasks_client.enqueue_task") as mock_task: | |
| resp = _dispatch_club_action( | |
| {"post": "trigger_event"}, user, club.id, {"event_id": 5} | |
| ) | |
| assert resp.status_code == 200 | |
| assert resp.data["status"] == "event triggered" | |
| mock_task.assert_called_once() | |
| def test_club_trigger_event_non_officer(user): | |
| club = DiscoveryClub.objects.create(name="NoTrig", description="d", creator=user) | |
| ClubMembership.objects.create(user=user, club=club, role="Member") | |
| resp = _dispatch_club_action({"post": "trigger_event"}, user, club.id) | |
| assert resp.status_code == 403 | |
| def test_club_trigger_event_not_member(user, other_user): | |
| club = DiscoveryClub.objects.create( | |
| name="StrangerClub", description="d", creator=other_user | |
| ) | |
| resp = _dispatch_club_action({"post": "trigger_event"}, user, club.id) | |
| assert resp.status_code == 403 | |
| # ========================================================================== | |
| # ProfileViewSet actions | |
| # ========================================================================== | |
| def _profile_action(name): | |
| return "/api/v1/" + name # profiles router path resolved below | |
| def test_profile_me(auth_client, user): | |
| resp = auth_client.get("/api/v1/profiles/me/") | |
| assert resp.status_code == 200 | |
| assert resp.data["user"]["username"] == "alice" | |
| def test_profile_update_personalization_valid(auth_client, user): | |
| resp = auth_client.post( | |
| "/api/v1/profiles/update_personalization/", | |
| {"theme": "dark", "accent_color": "purple"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 200 | |
| assert resp.data["settings"]["theme"] == "dark" | |
| def test_profile_update_personalization_invalid(auth_client): | |
| resp = auth_client.post( | |
| "/api/v1/profiles/update_personalization/", | |
| {"theme": "rainbow"}, # fails regex pattern | |
| format="json", | |
| ) | |
| assert resp.status_code == 400 | |
| assert "details" in resp.data | |
| def test_profile_update_settings_tier(auth_client, user): | |
| resp = auth_client.patch( | |
| "/api/v1/profiles/update_settings/", {"tier": "premium"}, format="json" | |
| ) | |
| assert resp.status_code == 200 | |
| assert resp.data["tier"] == "premium" | |
| def test_profile_update_settings_color_without_sponsor(auth_client): | |
| resp = auth_client.patch( | |
| "/api/v1/profiles/update_settings/", | |
| {"custom_username_color": "#FFD700"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 403 | |
| def test_profile_update_settings_color_with_sponsor_valid(auth_client, user): | |
| user.profile.unlocked_badges = ["Sponsor Or"] | |
| user.profile.save() | |
| resp = auth_client.patch( | |
| "/api/v1/profiles/update_settings/", | |
| {"custom_username_color": "#FFD700"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 200 | |
| assert resp.data["custom_username_color"] == "#FFD700" | |
| def test_profile_update_settings_color_with_sponsor_invalid(auth_client, user): | |
| user.profile.unlocked_badges = ["Sponsor Or"] | |
| user.profile.save() | |
| resp = auth_client.patch( | |
| "/api/v1/profiles/update_settings/", | |
| {"custom_username_color": "notacolor"}, | |
| format="json", | |
| ) | |
| assert resp.status_code == 400 | |
| def test_profile_claim_donation(auth_client, user): | |
| resp = auth_client.post("/api/v1/profiles/claim_donation/", {}, format="json") | |
| assert resp.status_code == 200 | |
| user.profile.refresh_from_db() | |
| assert "Sponsor Or" in user.profile.unlocked_badges | |
| assert user.profile.custom_username_color == "#FFD700" | |
| def test_profile_refill_quota(auth_client, user): | |
| from animetix.models import AITokenUsage, WalletTransaction | |
| AITokenUsage.objects.create(user=user, engine="gemini", total_tokens=10) | |
| before = user.profile.wallet_balance | |
| resp = auth_client.post("/api/v1/profiles/refill_quota/", {}, format="json") | |
| assert resp.status_code == 200 | |
| assert resp.data["new_balance"] == before + 1000 | |
| assert WalletTransaction.objects.filter( | |
| user=user, transaction_type="daily_grant" | |
| ).exists() | |
| def test_profile_generate_and_revoke_api_key(auth_client, user): | |
| resp = auth_client.post("/api/v1/profiles/generate_api_key/", {}, format="json") | |
| assert resp.status_code == 200 | |
| assert resp.data["api_key"].startswith("atx_") | |
| user.profile.refresh_from_db() | |
| assert user.profile.api_key_hash is not None | |
| resp2 = auth_client.post("/api/v1/profiles/revoke_api_key/", {}, format="json") | |
| assert resp2.status_code == 200 | |
| assert resp2.data["status"] == "revoked" | |
| user.profile.refresh_from_db() | |
| assert user.profile.api_key_hash is None | |
| # ========================================================================== | |
| # UserSearchView | |
| # ========================================================================== | |
| def test_user_search_too_short(auth_client): | |
| resp = auth_client.get(reverse("api_user_search"), {"q": "a"}) | |
| assert resp.status_code == 200 | |
| assert resp.data == [] | |
| def test_user_search_results_with_following_flag(auth_client, user, other_user): | |
| Friendship.objects.create(from_user=user, to_user=other_user) | |
| resp = auth_client.get(reverse("api_user_search"), {"q": "bob"}) | |
| assert resp.status_code == 200 | |
| assert len(resp.data) == 1 | |
| assert resp.data[0]["username"] == "bob" | |
| assert resp.data[0]["is_following"] is True | |
| # ========================================================================== | |
| # CreativeFusionViewSet like / remix | |
| # ========================================================================== | |
| def _make_fusion(creator, **kw): | |
| defaults = dict( | |
| title_a="A", | |
| title_b="B", | |
| media_type_a="Anime", | |
| media_type_b="Manga", | |
| scenario_text="story", | |
| ) | |
| defaults.update(kw) | |
| return CreativeFusion.objects.create(creator=creator, **defaults) | |
| def test_fusion_like_toggle(auth_client, user, other_user): | |
| fusion = _make_fusion(other_user, is_public=True) | |
| url = reverse("api-fusions") + f"{fusion.id}/like/" | |
| r1 = auth_client.post(url) | |
| assert r1.status_code == 200 | |
| assert r1.data["status"] == "liked" | |
| assert r1.data["likes_count"] == 1 | |
| r2 = auth_client.post(url) | |
| assert r2.data["status"] == "unliked" | |
| assert r2.data["likes_count"] == 0 | |
| def test_fusion_remix(auth_client, user, other_user): | |
| parent = _make_fusion(other_user, is_public=True, chaos_level=10) | |
| url = reverse("api-fusions") + f"{parent.id}/remix/" | |
| resp = auth_client.post(url, {"chaos_level": 99}, format="json") | |
| assert resp.status_code == 201 | |
| assert resp.data["chaos_level"] == 99 | |
| assert resp.data["parent"] == parent.id | |
| # ========================================================================== | |
| # SocialViewSet dashboard / toggle_follow | |
| # ========================================================================== | |
| def test_social_dashboard(auth_client, user, other_user): | |
| Friendship.objects.create(from_user=user, to_user=other_user) | |
| Friendship.objects.create(from_user=other_user, to_user=user) | |
| resp = auth_client.get(reverse("api_social_dashboard")) | |
| assert resp.status_code == 200 | |
| assert len(resp.data["following"]) == 1 | |
| assert len(resp.data["followers"]) == 1 | |
| def test_social_dashboard_anonymous_returns_empty_network(api_client): | |
| """La page Communauté est publique : un visiteur anonyme charge le hub | |
| (cartes de navigation) avec un réseau vide, au lieu d'un 401 qui casse | |
| toute la page.""" | |
| resp = api_client.get(reverse("api_social_dashboard")) | |
| assert resp.status_code == 200 | |
| assert resp.data["following"] == [] | |
| assert resp.data["followers"] == [] | |
| def test_social_toggle_follow_and_unfollow(auth_client, user, other_user): | |
| url = reverse("api_social_toggle_follow", args=[other_user.id]) | |
| r1 = auth_client.post(url) | |
| assert r1.status_code == 200 | |
| assert r1.data["status"] == "followed" | |
| assert Friendship.objects.filter(from_user=user, to_user=other_user).exists() | |
| r2 = auth_client.post(url) | |
| assert r2.data["status"] == "unfollowed" | |
| assert not Friendship.objects.filter(from_user=user, to_user=other_user).exists() | |
| def test_social_toggle_follow_self(auth_client, user): | |
| url = reverse("api_social_toggle_follow", args=[user.id]) | |
| resp = auth_client.post(url) | |
| assert resp.status_code == 400 | |
| assert "yourself" in resp.data["error"] | |
| def test_social_toggle_follow_missing_user(auth_client): | |
| url = reverse("api_social_toggle_follow", args=[999999]) | |
| resp = auth_client.post(url) | |
| assert resp.status_code == 404 | |
| # ========================================================================== | |
| # LeaderboardView | |
| # ========================================================================== | |
| def test_leaderboard_ranked(auth_client, user, other_user): | |
| user.profile.ranked_points = 500 | |
| user.profile.save() | |
| other_user.profile.ranked_points = 100 | |
| other_user.profile.save() | |
| resp = auth_client.get(reverse("api_leaderboard")) | |
| assert resp.status_code == 200 | |
| assert resp.data[0]["username"] == "alice" | |
| assert resp.data[0]["is_me"] is True | |
| def test_leaderboard_xp_mode(auth_client, user): | |
| user.profile.xp = 1234 | |
| user.profile.save() | |
| resp = auth_client.get(reverse("api_leaderboard"), {"mode": "xp"}) | |
| assert resp.status_code == 200 | |
| assert resp.data[0]["points"] == 1234 | |
| # ========================================================================== | |
| # ProfileDetailView | |
| # ========================================================================== | |
| def test_profile_detail_returns_achievements_and_fusions(other_user): | |
| """Regression: ``user.user_achievements`` (invalid reverse accessor) used to | |
| crash ProfileDetailView with a 500 for every existing user — fixed to | |
| ``userachievement_set``. | |
| """ | |
| ach = Achievement.objects.create(code="A1", name="First", description="d", icon="x") | |
| UserAchievement.objects.create(user=other_user, achievement=ach) | |
| _make_fusion(other_user, is_public=True) | |
| client = APIClient() | |
| client.force_authenticate(user=other_user) | |
| resp = client.get(reverse("api_profile_detail", args=["bob"])) | |
| assert resp.status_code == 200 | |
| assert resp.data["achievements_count"] == 1 | |
| assert len(resp.data["recent_fusions"]) == 1 | |
| def test_profile_detail_not_found(auth_client): | |
| resp = auth_client.get(reverse("api_profile_detail", args=["ghost"])) | |
| assert resp.status_code == 404 | |
| # ========================================================================== | |
| # MyCollectionView | |
| # ========================================================================== | |
| def test_my_collection(auth_client, user, other_user): | |
| _make_fusion(user) | |
| liked = _make_fusion(other_user, is_public=True) | |
| liked.likes.add(user) | |
| resp = auth_client.get(reverse("api_collection")) | |
| assert resp.status_code == 200 | |
| assert len(resp.data["my_creations"]) == 1 | |
| assert len(resp.data["my_likes"]) == 1 | |
| # ========================================================================== | |
| # NotificationListView (GET marks read + POST) | |
| # ========================================================================== | |
| def test_notification_list_known_bug(user): | |
| """NotificationListView.get calls ``.update()`` on a sliced queryset | |
| (``[:50]``) which Django forbids -> TypeError/500. BUG: the endpoint is | |
| broken whenever it is hit. We assert the real, current behaviour. | |
| """ | |
| Notification.objects.create(user=user, title="Hi", message="m", is_read=False) | |
| client = APIClient(raise_request_exception=False) | |
| client.force_authenticate(user=user) | |
| resp = client.get(reverse("api_notifications")) | |
| assert resp.status_code == 500 | |
| def test_notification_post_known_bug(user): | |
| """Same sliced-queryset ``.update()`` bug on the POST handler.""" | |
| Notification.objects.create(user=user, title="Hi", message="m", is_read=False) | |
| client = APIClient(raise_request_exception=False) | |
| client.force_authenticate(user=user) | |
| resp = client.post(reverse("api_notifications"), {}, format="json") | |
| assert resp.status_code == 500 | |
| # ========================================================================== | |
| # GameplaySessionListView | |
| # ========================================================================== | |
| def test_gameplay_history(auth_client, user): | |
| GameplaySession.objects.create( | |
| user=user, | |
| game_mode="classic", | |
| media_type="Anime", | |
| target_item="Naruto", | |
| history=[], | |
| ) | |
| resp = auth_client.get(reverse("api_gameplay_history"), {"limit": 10}) | |
| assert resp.status_code == 200 | |
| assert len(resp.data) == 1 | |
| assert resp.data[0]["target_item"] == "Naruto" | |
| # ========================================================================== | |
| # ClubEventViewSet create + join | |
| # ========================================================================== | |
| def test_club_event_create_officer(auth_client, user): | |
| club = DiscoveryClub.objects.create(name="EvClub", description="d", creator=user) | |
| ClubMembership.objects.create(user=user, club=club, role="Officer") | |
| resp = auth_client.post( | |
| reverse("api-club-events"), | |
| { | |
| "club": club.id, | |
| "title": "Movie Night", | |
| "description": "watch", | |
| "event_date": timezone.now().isoformat(), | |
| }, | |
| format="json", | |
| ) | |
| assert resp.status_code == 201 | |
| assert ClubEvent.objects.filter(club=club, title="Movie Night").exists() | |
| def test_club_event_create_non_officer_denied(auth_client, user): | |
| club = DiscoveryClub.objects.create(name="EvClub2", description="d", creator=user) | |
| ClubMembership.objects.create(user=user, club=club, role="Member") | |
| resp = auth_client.post( | |
| reverse("api-club-events"), | |
| { | |
| "club": club.id, | |
| "title": "Nope", | |
| "description": "x", | |
| "event_date": timezone.now().isoformat(), | |
| }, | |
| format="json", | |
| ) | |
| assert resp.status_code == 403 | |
| def test_club_event_create_not_member_denied(auth_client, user, other_user): | |
| club = DiscoveryClub.objects.create( | |
| name="EvClub3", description="d", creator=other_user | |
| ) | |
| resp = auth_client.post( | |
| reverse("api-club-events"), | |
| { | |
| "club": club.id, | |
| "title": "Nope", | |
| "description": "x", | |
| "event_date": timezone.now().isoformat(), | |
| }, | |
| format="json", | |
| ) | |
| assert resp.status_code == 403 | |
| # ClubEventViewSet ``join`` action has no URL route; dispatch it directly. | |
| def _dispatch_event_join(user, event_id): | |
| from animetix.api.social import ClubEventViewSet | |
| from rest_framework.test import APIRequestFactory, force_authenticate | |
| factory = APIRequestFactory() | |
| request = factory.post("/", {}, format="json") | |
| force_authenticate(request, user=user) | |
| view = ClubEventViewSet.as_view({"post": "join"}) | |
| return view(request, pk=event_id) | |
| def test_club_event_join_toggle(user): | |
| club = DiscoveryClub.objects.create(name="EvClub4", description="d", creator=user) | |
| ClubMembership.objects.create(user=user, club=club, role="Member") | |
| event = ClubEvent.objects.create( | |
| club=club, title="Party", description="d", event_date=timezone.now() | |
| ) | |
| r1 = _dispatch_event_join(user, event.id) | |
| assert r1.status_code == 200 | |
| assert r1.data["status"] == "joined" | |
| r2 = _dispatch_event_join(user, event.id) | |
| assert r2.data["status"] == "left" | |
| def test_club_event_join_not_member(user, other_user): | |
| club = DiscoveryClub.objects.create( | |
| name="EvClub5", description="d", creator=other_user | |
| ) | |
| event = ClubEvent.objects.create( | |
| club=club, title="Party", description="d", event_date=timezone.now() | |
| ) | |
| resp = _dispatch_event_join(user, event.id) | |
| assert resp.status_code == 403 | |