animetix-web / tests /api /test_social_coverage.py
MissawB's picture
Upload folder using huggingface_hub (part 6)
5294c4d verified
Raw
History Blame Contribute Delete
21.6 kB
"""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
@pytest.fixture(autouse=True)
def _locmem_cache():
"""Force LocMem cache so DRF throttling does not hit redis (CI sets REDIS_URL)."""
with override_settings(CACHES=LOCMEM_CACHE):
yield
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="password")
@pytest.fixture
def other_user(db):
return User.objects.create_user(username="bob", password="password")
@pytest.fixture
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