Coverage for ivatar/test_access_stats.py: 98%
90 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1import base64
2from django.test import TransactionTestCase, Client, override_settings
3from django.contrib.auth.models import User
4from ivatar.ivataraccount.models import (
5 ConfirmedEmail,
6 Photo,
7 ConfirmedEmailAccessStat,
8)
9from ivatar.access_stats import stats_manager
10from django.core.cache import cache
11import time
12from unittest.mock import patch
15@override_settings(
16 CACHES={
17 "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
18 "filesystem": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
19 },
20 CACHE_RESPONSE=False,
21 STATS_BATCH_SIZE=100,
22 STATS_FLUSH_TIMEOUT=5, # Short timeout for testing
23 ASYNC_ACCESS_COUNT=True,
24)
25class BatchStatsTest(TransactionTestCase):
26 def setUp(self):
27 self.username = "testuser_" + str(time.time())
28 self.password = "password"
29 self.user = User.objects.create_user(
30 username=self.username, password=self.password
31 )
32 self.client = Client()
34 # Create a confirmed email and photo
35 self.email_address = f"test_{time.time()}@example.com"
36 self.email = ConfirmedEmail.objects.create(
37 user=self.user, email=self.email_address, ip_address="127.0.0.1"
38 )
40 # Create a dummy photo (minimal PNG, Base64-encoded for pycco compatibility)
41 png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAACklEQVQIHWNgYAAAAAMAAWveibQAAAAASUVORK5CYII="
42 png_data = base64.b64decode(png_base64)
43 self.photo = Photo.objects.create(
44 user=self.user, data=png_data, format="png", ip_address="127.0.0.1"
45 )
46 self.email.photo = self.photo
47 self.email.save()
49 # Clear keys for this test
50 model_name = self.email.__class__.__name__
51 pk = self.email.pk
52 self.email_key = f"stats:access_count:{model_name}:{pk}"
53 self.start_time_key = f"stats:start_time:{model_name}:{pk}"
55 cache.delete(self.email_key)
56 cache.delete(self.start_time_key)
58 # Ensure stats_manager has clean state
59 stats_manager._local_dirty_keys.clear()
60 stats_manager._local_start_times.clear()
62 # Mock threading.Thread to run synchronously
63 self.thread_patcher = patch("ivatar.access_stats.threading.Thread")
64 self.mock_thread_cls = self.thread_patcher.start()
66 # Set up side effect to run target immediately
67 def side_effect(target=None, args=(), **kwargs):
68 class SyncThread:
69 def start(self):
70 if target:
71 target(*args)
73 return SyncThread()
75 self.mock_thread_cls.side_effect = side_effect
77 def tearDown(self):
78 self.thread_patcher.stop()
80 def _get_email_access_count(self):
81 """Read access_count from the stat table for self.email."""
82 try:
83 return ConfirmedEmailAccessStat.objects.get(email=self.email).access_count
84 except ConfirmedEmailAccessStat.DoesNotExist:
85 return 0
87 @override_settings(ASYNC_ACCESS_COUNT=False)
88 def test_sync_update_when_disabled(self):
89 """
90 Verify that updates are synchronous when feature flag is disabled.
91 """
92 stats_manager.update_access_count(self.email)
94 # Check stat table updated immediately
95 self.assertEqual(self._get_email_access_count(), 1)
97 # Check cache should be empty (or at least not used for this logic)
98 self.assertIsNone(cache.get(self.email_key))
100 def test_normal_increment_cached(self):
101 """
102 Verify that a single request increments cache but does not update DB immediately.
103 """
104 stats_manager.update_access_count(self.email)
106 # Check cache
107 self.assertEqual(cache.get(self.email_key), 1)
109 # Check stat table (should be 0)
110 self.assertEqual(self._get_email_access_count(), 0)
112 def test_batch_flush(self):
113 """
114 Verify that hitting the batch limit triggers a DB update.
115 """
116 # Increment 99 times (limit is 100)
117 for _ in range(99):
118 stats_manager.update_access_count(self.email)
120 self.assertEqual(cache.get(self.email_key), 99)
121 self.assertEqual(self._get_email_access_count(), 0)
123 # 100th increment -> Triggers flush (synchronously due to mock)
124 stats_manager.update_access_count(self.email)
126 # No wait needed
128 # Check stat table
129 self.assertEqual(self._get_email_access_count(), 100)
131 # Check cache reset
132 self.assertEqual(cache.get(self.email_key), 0)
134 def test_time_flush(self):
135 """
136 Verify that the time-based flush works.
137 Timeout set to 5 seconds in override_settings.
138 """
139 # 1. First increment sets start time (worker-local)
140 stats_manager.update_access_count(self.email)
142 # Verify start time is tracked locally
143 self.assertIsNotNone(stats_manager._local_start_times.get(self.email_key))
145 # Verify no DB update yet
146 self.assertEqual(self._get_email_access_count(), 0)
148 # 2. Wait for timeout > 5s
149 time.sleep(6)
151 # 3. Next increment should trigger flush due to timeout (synchronously)
152 stats_manager.update_access_count(self.email)
154 # No wait needed
156 # Check stat table: should have 2 updates (1 from before wait + 1 from trigger)
157 self.assertEqual(self._get_email_access_count(), 2)
159 # Check cache reset
160 self.assertEqual(cache.get(self.email_key), 0)
161 # Start time should be cleared
162 self.assertIsNone(stats_manager._local_start_times.get(self.email_key))
164 def test_time_flush_rearms_after_flush(self):
165 """
166 Verify the time-based trigger fires repeatedly, not just once per key.
167 The old cache-based start time was never rewritten after the first
168 flush (incr on the now-zero key returns 1 without raising), so
169 subsequent timeout windows silently never flushed.
170 """
171 # Window 1: arm + trigger
172 stats_manager.update_access_count(self.email)
173 time.sleep(6)
174 stats_manager.update_access_count(self.email)
175 self.assertEqual(self._get_email_access_count(), 2)
177 # Window 2: must flush again
178 stats_manager.update_access_count(self.email)
179 time.sleep(6)
180 stats_manager.update_access_count(self.email)
181 self.assertEqual(self._get_email_access_count(), 4)
183 def test_atexit_handler(self):
184 """
185 Verify that the cleanup handler flushes remaining counts.
186 """
187 # Add some counts
188 stats_manager.update_access_count(self.email)
189 stats_manager.update_access_count(self.email)
190 stats_manager.update_access_count(self.email)
192 self.assertEqual(cache.get(self.email_key), 3)
193 self.assertEqual(self._get_email_access_count(), 0)
195 # Manually call the handler
196 stats_manager.flush_all_dirty_keys()
198 # Check stat table
199 self.assertEqual(self._get_email_access_count(), 3)
201 # Check cache decremented
202 self.assertEqual(cache.get(self.email_key), 0)