Coverage for ivatar/test_conditional_requests.py: 100%
65 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
1"""
2Test suite for Fast ETag and Conditional Request (304 Not Modified) handling.
3"""
5from io import BytesIO
6import hashlib
8from PIL import Image
9from django.contrib.auth.models import User
10from django.test import TestCase, override_settings
11from django.utils.http import http_date
13from ivatar.ivataraccount.models import ConfirmedEmail, Photo
16@override_settings(
17 CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
18 CACHE_RESPONSE=False,
19)
20class ConditionalRequestTest(TestCase):
21 """
22 Test 304 Not Modified fast-paths for If-None-Match and If-Modified-Since.
23 """
25 def setUp(self):
26 self.user = User.objects.create(username="etagtestuser")
28 # Create a test photo (100x100 PNG)
29 img = Image.new("RGBA", (100, 100), (200, 50, 50, 255))
30 buf = BytesIO()
31 img.save(buf, format="PNG")
32 self.photo = Photo.objects.create(
33 user=self.user,
34 data=buf.getvalue(),
35 format="png",
36 ip_address="127.0.0.1",
37 )
39 self.email = "etagtest@example.com"
40 self.digest = hashlib.md5(self.email.encode("utf-8")).hexdigest()
41 self.confirmed_email = ConfirmedEmail.objects.create(
42 user=self.user,
43 email=self.email,
44 digest=self.digest,
45 photo=self.photo,
46 )
48 def test_initial_request_returns_200_with_etag_and_last_modified(self):
49 """
50 Standard request without conditional headers returns 200 with ETag and Last-Modified.
51 """
52 response = self.client.get(f"/avatar/{self.digest}?s=80")
53 self.assertEqual(response.status_code, 200)
54 self.assertEqual(response["Content-Type"], "image/png")
55 self.assertIn("Etag", response)
56 self.assertEqual(response["Etag"], f'"{self.digest}"')
57 self.assertIn("Last-Modified", response)
58 self.assertTrue(len(response.content) > 0)
60 def test_if_none_match_exact_returns_304(self):
61 """
62 Matching If-None-Match returns 304 Not Modified with empty body.
63 """
64 response = self.client.get(
65 f"/avatar/{self.digest}?s=80",
66 HTTP_IF_NONE_MATCH=f'"{self.digest}"',
67 )
68 self.assertEqual(response.status_code, 304)
69 self.assertEqual(response.content, b"")
70 self.assertEqual(response["Etag"], f'"{self.digest}"')
72 def test_if_none_match_weak_returns_304(self):
73 """
74 Weak ETag in If-None-Match (W/"...") returns 304 per RFC 7232.
75 """
76 response = self.client.get(
77 f"/avatar/{self.digest}?s=80",
78 HTTP_IF_NONE_MATCH=f'W/"{self.digest}"',
79 )
80 self.assertEqual(response.status_code, 304)
81 self.assertEqual(response.content, b"")
83 def test_if_none_match_wildcard_returns_304(self):
84 """
85 Wildcard If-None-Match: * returns 304 when resource exists.
86 """
87 response = self.client.get(
88 f"/avatar/{self.digest}?s=80",
89 HTTP_IF_NONE_MATCH="*",
90 )
91 self.assertEqual(response.status_code, 304)
92 self.assertEqual(response.content, b"")
94 def test_if_none_match_mismatch_returns_200(self):
95 """
96 Mismatched If-None-Match returns full 200 OK image response.
97 """
98 response = self.client.get(
99 f"/avatar/{self.digest}?s=80",
100 HTTP_IF_NONE_MATCH='"different_etag_value"',
101 )
102 self.assertEqual(response.status_code, 200)
103 self.assertTrue(len(response.content) > 0)
105 def test_if_modified_since_not_modified_returns_304(self):
106 """
107 If-Modified-Since with date matching or after photo add_date returns 304.
108 """
109 future_date = http_date(self.photo.add_date.timestamp() + 3600)
110 response = self.client.get(
111 f"/avatar/{self.digest}?s=80",
112 HTTP_IF_MODIFIED_SINCE=future_date,
113 )
114 self.assertEqual(response.status_code, 304)
115 self.assertEqual(response.content, b"")
117 def test_if_modified_since_modified_returns_200(self):
118 """
119 If-Modified-Since with date prior to photo add_date returns 200.
120 """
121 past_date = http_date(self.photo.add_date.timestamp() - 3600)
122 response = self.client.get(
123 f"/avatar/{self.digest}?s=80",
124 HTTP_IF_MODIFIED_SINCE=past_date,
125 )
126 self.assertEqual(response.status_code, 200)
127 self.assertTrue(len(response.content) > 0)
129 def test_conditional_request_on_default_avatar_generator(self):
130 """
131 Generated default avatars (retro/monsterid/etc.) support 304 on If-None-Match.
132 """
133 unknown_digest = "0123456789abcdef0123456789abcdef"
135 # First request generates and returns 200
136 response1 = self.client.get(
137 f"/avatar/{unknown_digest}?d=retro&forcedefault=y&s=64"
138 )
139 self.assertEqual(response1.status_code, 200)
140 self.assertIn("Etag", response1)
142 # Conditional request returns 304
143 response2 = self.client.get(
144 f"/avatar/{unknown_digest}?d=retro&forcedefault=y&s=64",
145 HTTP_IF_NONE_MATCH=f'"{unknown_digest}"',
146 )
147 self.assertEqual(response2.status_code, 304)
148 self.assertEqual(response2.content, b"")
150 def test_conditional_request_does_not_convert_redirect_to_304(self):
151 """
152 Redirects (e.g. to Gravatar proxy) must not be converted to 304.
153 """
154 missing_digest = "fedcba9876543210fedcba9876543210"
155 response = self.client.get(
156 f"/avatar/{missing_digest}?gravatarproxy=y",
157 HTTP_IF_NONE_MATCH=f'"{missing_digest}"',
158 )
159 self.assertEqual(response.status_code, 302)