Coverage for ivatar/test_access_stats.py: 98%
80 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 11:51 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 11:51 +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()
61 # Mock threading.Thread to run synchronously
62 self.thread_patcher = patch("ivatar.access_stats.threading.Thread")
63 self.mock_thread_cls = self.thread_patcher.start()
65 # Set up side effect to run target immediately
66 def side_effect(target=None, args=(), **kwargs):
67 class SyncThread:
68 def start(self):
69 if target:
70 target(*args)
72 return SyncThread()
74 self.mock_thread_cls.side_effect = side_effect
76 def tearDown(self):
77 self.thread_patcher.stop()
79 def _get_email_access_count(self):
80 """Read access_count from the stat table for self.email."""
81 try:
82 return ConfirmedEmailAccessStat.objects.get(email=self.email).access_count
83 except ConfirmedEmailAccessStat.DoesNotExist:
84 return 0
86 @override_settings(ASYNC_ACCESS_COUNT=False)
87 def test_sync_update_when_disabled(self):
88 """
89 Verify that updates are synchronous when feature flag is disabled.
90 """
91 stats_manager.update_access_count(self.email)
93 # Check stat table updated immediately
94 self.assertEqual(self._get_email_access_count(), 1)
96 # Check cache should be empty (or at least not used for this logic)
97 self.assertIsNone(cache.get(self.email_key))
99 def test_normal_increment_cached(self):
100 """
101 Verify that a single request increments cache but does not update DB immediately.
102 """
103 stats_manager.update_access_count(self.email)
105 # Check cache
106 self.assertEqual(cache.get(self.email_key), 1)
108 # Check stat table (should be 0)
109 self.assertEqual(self._get_email_access_count(), 0)
111 def test_batch_flush(self):
112 """
113 Verify that hitting the batch limit triggers a DB update.
114 """
115 # Increment 99 times (limit is 100)
116 for _ in range(99):
117 stats_manager.update_access_count(self.email)
119 self.assertEqual(cache.get(self.email_key), 99)
120 self.assertEqual(self._get_email_access_count(), 0)
122 # 100th increment -> Triggers flush (synchronously due to mock)
123 stats_manager.update_access_count(self.email)
125 # No wait needed
127 # Check stat table
128 self.assertEqual(self._get_email_access_count(), 100)
130 # Check cache reset
131 self.assertEqual(cache.get(self.email_key), 0)
133 def test_time_flush(self):
134 """
135 Verify that the time-based flush works.
136 Timeout set to 5 seconds in override_settings.
137 """
138 # 1. First increment sets start time
139 stats_manager.update_access_count(self.email)
141 # Verify start time is set
142 self.assertIsNotNone(cache.get(self.start_time_key))
144 # Verify no DB update yet
145 self.assertEqual(self._get_email_access_count(), 0)
147 # 2. Wait for timeout > 5s
148 time.sleep(6)
150 # 3. Next increment should trigger flush due to timeout (synchronously)
151 stats_manager.update_access_count(self.email)
153 # No wait needed
155 # Check stat table: should have 2 updates (1 from before wait + 1 from trigger)
156 self.assertEqual(self._get_email_access_count(), 2)
158 # Check cache reset
159 self.assertEqual(cache.get(self.email_key), 0)
160 # Start time should be cleared
161 self.assertIsNone(cache.get(self.start_time_key))
163 def test_atexit_handler(self):
164 """
165 Verify that the cleanup handler flushes remaining counts.
166 """
167 # Add some counts
168 stats_manager.update_access_count(self.email)
169 stats_manager.update_access_count(self.email)
170 stats_manager.update_access_count(self.email)
172 self.assertEqual(cache.get(self.email_key), 3)
173 self.assertEqual(self._get_email_access_count(), 0)
175 # Manually call the handler
176 stats_manager.flush_all_dirty_keys()
178 # Check stat table
179 self.assertEqual(self._get_email_access_count(), 3)
181 # Check cache decremented
182 self.assertEqual(cache.get(self.email_key), 0)