|
| 1 | +import datetime |
1 | 2 | import os |
2 | 3 |
|
3 | 4 | import pytest |
| 5 | +from django.utils import timezone |
4 | 6 | from rest_framework import status |
5 | 7 | from rest_framework.test import APIClient, APITestCase |
6 | 8 |
|
7 | | -from streamer.factories import AnimeRoomFactory |
| 9 | +from streamer.factories import AnimeRoomFactory, AnimeUserFactory |
| 10 | +from streamer.models import AnimeReaction, ReactionStat |
| 11 | + |
| 12 | + |
| 13 | +def _clear_reaction_tables() -> None: |
| 14 | + """Drop reaction rows so reaction-count assertions are deterministic. |
| 15 | +
|
| 16 | + The test DB uses a ``MIRROR`` config, so rows committed by ``transaction=True`` |
| 17 | + tests (e.g. the consumer folding test) are not flushed between runs. Called in |
| 18 | + ``setUp`` it runs inside the per-test transaction and is rolled back afterwards, |
| 19 | + so it only hides that committed pollution for the duration of one test. |
| 20 | + """ |
| 21 | + ReactionStat.objects.all().delete() |
| 22 | + AnimeReaction.objects.all().delete(hard=True) |
8 | 23 |
|
9 | 24 |
|
10 | 25 | class TestVersionCheckAPI(APITestCase): |
@@ -102,3 +117,106 @@ def test_lobby_resolve_not_found_404(self): |
102 | 117 |
|
103 | 118 | response = self.client.get(self.endpoint(uuid.uuid4())) |
104 | 119 | assert response.status_code == status.HTTP_404_NOT_FOUND |
| 120 | + |
| 121 | + |
| 122 | +class TestStatsDaysParam(APITestCase): |
| 123 | + """``?days=N`` の検証(未指定→既定、範囲外→400)。""" |
| 124 | + |
| 125 | + def setUp(self) -> None: |
| 126 | + self.client = APIClient() |
| 127 | + self.endpoint = "/api/v1/statistics/anime-store/active-user-per-day" |
| 128 | + |
| 129 | + @pytest.mark.django_db |
| 130 | + def test_missing_days_defaults_ok_200(self): |
| 131 | + response = self.client.get(self.endpoint) |
| 132 | + assert response.status_code == status.HTTP_200_OK |
| 133 | + |
| 134 | + @pytest.mark.django_db |
| 135 | + def test_days_out_of_range_400(self): |
| 136 | + for bad in ("0", "366", "-1", "abc", "1.5"): |
| 137 | + response = self.client.get(self.endpoint, {"days": bad}) |
| 138 | + assert response.status_code == status.HTTP_400_BAD_REQUEST, bad |
| 139 | + |
| 140 | + @pytest.mark.django_db |
| 141 | + def test_days_in_range_ok_200(self): |
| 142 | + for ok in ("1", "30", "365"): |
| 143 | + response = self.client.get(self.endpoint, {"days": ok}) |
| 144 | + assert response.status_code == status.HTTP_200_OK, ok |
| 145 | + |
| 146 | + |
| 147 | +class TestStatsPeriodScope(APITestCase): |
| 148 | + """期間スコープ(累計・リアクション)が days に従うことを確認。""" |
| 149 | + |
| 150 | + def setUp(self) -> None: |
| 151 | + self.client = APIClient() |
| 152 | + _clear_reaction_tables() |
| 153 | + |
| 154 | + @staticmethod |
| 155 | + def _make_reaction(room, reaction_type, when): |
| 156 | + reaction = AnimeReaction.objects.create( |
| 157 | + room_id=room, reaction_type=reaction_type |
| 158 | + ) |
| 159 | + AnimeReaction.objects.filter(pk=reaction.pk).update(created_at=when) |
| 160 | + return reaction |
| 161 | + |
| 162 | + @pytest.mark.django_db |
| 163 | + def test_user_all_count_respects_days(self): |
| 164 | + """40 日前のユーザーは 30 日窓では数えず 365 日窓では数える。 |
| 165 | +
|
| 166 | + テスト DB は ``MIRROR`` 設定で他テストの行が残りうるため、絶対値ではなく |
| 167 | + 「40 日前ユーザーを 1 人足すと 365 日窓だけ +1 される」差分で検証する。 |
| 168 | + """ |
| 169 | + from streamer.models import AnimeUser |
| 170 | + |
| 171 | + AnimeUserFactory() # recent(auto_now_add で created_at=now) |
| 172 | + old = AnimeUserFactory() |
| 173 | + AnimeUser.objects.filter(pk=old.pk).update( |
| 174 | + created_at=timezone.now() - datetime.timedelta(days=40) |
| 175 | + ) |
| 176 | + endpoint = "/api/v1/statistics/anime-store/anime-user-all-count" |
| 177 | + |
| 178 | + c30 = self.client.get(endpoint, {"days": "30"}).data["data"]["count"] |
| 179 | + c365 = self.client.get(endpoint, {"days": "365"}).data["data"]["count"] |
| 180 | + assert c30 >= 1 # recent は 30 日窓に入る |
| 181 | + assert c365 - c30 >= 1 # 40 日前ユーザーは 365 日窓のみに入る |
| 182 | + |
| 183 | + @pytest.mark.django_db |
| 184 | + def test_reaction_counts_come_from_reactionstat_and_live(self): |
| 185 | + """リアクション集計は ReactionStat(畳み込み済み)と alive ルームの合算。""" |
| 186 | + today = timezone.now().date() |
| 187 | + ReactionStat.objects.create(date=today, reaction_type="S", count=5) |
| 188 | + live_room = AnimeRoomFactory() # alive |
| 189 | + self._make_reaction(live_room, "S", timezone.now()) |
| 190 | + |
| 191 | + by_type = self.client.get( |
| 192 | + "/api/v1/statistics/anime-store/anime-reaction-count", {"days": "30"} |
| 193 | + ) |
| 194 | + smile = next(r for r in by_type.data["data"] if r["reaction_type"] == "smile") |
| 195 | + assert smile["count"] == 6 # 5 (folded) + 1 (live) |
| 196 | + |
| 197 | + total = self.client.get( |
| 198 | + "/api/v1/statistics/anime-store/anime-reaction-all-count", {"days": "30"} |
| 199 | + ) |
| 200 | + assert total.data["data"]["count"] == 6 |
| 201 | + |
| 202 | + |
| 203 | +class TestStatsCache(APITestCase): |
| 204 | + """キャッシュヒット時に DB の変化が TTL 内では反映されないこと。""" |
| 205 | + |
| 206 | + def setUp(self) -> None: |
| 207 | + self.client = APIClient() |
| 208 | + self.endpoint = "/api/v1/statistics/anime-store/anime-reaction-all-count" |
| 209 | + _clear_reaction_tables() |
| 210 | + |
| 211 | + @pytest.mark.django_db |
| 212 | + def test_response_is_cached(self): |
| 213 | + today = timezone.now().date() |
| 214 | + ReactionStat.objects.create(date=today, reaction_type="S", count=3) |
| 215 | + |
| 216 | + first = self.client.get(self.endpoint, {"days": "30"}) |
| 217 | + assert first.data["data"]["count"] == 3 |
| 218 | + |
| 219 | + # キャッシュ後に DB を増やしても、同一キーは TTL 内でキャッシュ値を返す。 |
| 220 | + ReactionStat.objects.filter(date=today, reaction_type="S").update(count=99) |
| 221 | + second = self.client.get(self.endpoint, {"days": "30"}) |
| 222 | + assert second.data["data"]["count"] == 3 # キャッシュヒット |
0 commit comments