Skip to content

Commit f52a6d2

Browse files
committed
test: add integration tests for PasteService and update API tests
- Introduce `test_paste_service.py` with comprehensive integration tests for paste creation, retrieval, editing, and deletion - Expand `test_paste_routes.py` with additional API tests for expiration handling, caching headers, and error scenarios - Update `conftest.py` with rate limit bypassing and new test fixtures - Improve test coverage and ensure reliability across scenarios
1 parent 2ff3e55 commit f52a6d2

File tree

4 files changed

+1039
-7
lines changed

4 files changed

+1039
-7
lines changed

backend/tests/api/conftest.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
"""Fixtures for API endpoint tests."""
2+
import pytest
23
import pytest_asyncio
34
from httpx import AsyncClient
45

56

7+
@pytest.fixture
8+
def bypass_headers():
9+
"""Headers with bypass token to skip rate limiting."""
10+
return {"Authorization": "test_bypass_token_12345"}
11+
12+
613
@pytest_asyncio.fixture
7-
async def authenticated_paste(test_client: AsyncClient, sample_paste_data):
14+
async def authenticated_paste(test_client: AsyncClient, sample_paste_data, bypass_headers):
815
"""Create a paste and return it with auth tokens."""
9-
response = await test_client.post("/pastes", json=sample_paste_data)
16+
response = await test_client.post("/pastes", json=sample_paste_data, headers=bypass_headers)
1017
assert response.status_code == 200
1118
return response.json()
Lines changed: 316 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"""API tests for paste endpoints."""
2+
from datetime import datetime, timedelta, timezone
3+
24
import pytest
35
from httpx import AsyncClient
46

57

68
@pytest.mark.asyncio
7-
class TestPasteCreationAPI:
9+
class TestPasteCreateAPI:
810
"""API tests for paste creation endpoint."""
911

1012
async def test_create_paste_returns_plaintext_tokens(
11-
self, test_client: AsyncClient, sample_paste_data
13+
self, test_client: AsyncClient, sample_paste_data, bypass_headers
1214
):
1315
"""POST /pastes should return plaintext tokens to user."""
14-
response = await test_client.post("/pastes", json=sample_paste_data)
16+
response = await test_client.post("/pastes", json=sample_paste_data, headers=bypass_headers)
1517

1618
assert response.status_code == 200
1719
data = response.json()
@@ -24,17 +26,326 @@ async def test_create_paste_returns_plaintext_tokens(
2426
assert len(data["edit_token"]) == 32 # UUID hex
2527
assert len(data["delete_token"]) == 32
2628

29+
async def test_create_paste_with_expiration(
30+
self, test_client: AsyncClient, bypass_headers
31+
):
32+
"""POST /pastes should handle expiration time."""
33+
expires_at = (datetime.now(tz=timezone.utc) + timedelta(days=1)).isoformat()
34+
paste_data = {
35+
"title": "Expiring Paste",
36+
"content": "This will expire",
37+
"content_language": "plain_text",
38+
"expires_at": expires_at,
39+
}
40+
41+
response = await test_client.post("/pastes", json=paste_data, headers=bypass_headers)
42+
43+
assert response.status_code == 200
44+
data = response.json()
45+
assert data["expires_at"] is not None
46+
47+
async def test_create_paste_without_expiration(
48+
self, test_client: AsyncClient, bypass_headers
49+
):
50+
"""POST /pastes should create permanent paste without expiration."""
51+
paste_data = {
52+
"title": "Permanent Paste",
53+
"content": "No expiration",
54+
"content_language": "plain_text",
55+
}
56+
57+
response = await test_client.post("/pastes", json=paste_data, headers=bypass_headers)
58+
59+
assert response.status_code == 200
60+
data = response.json()
61+
assert data["expires_at"] is None
62+
63+
64+
@pytest.mark.asyncio
65+
class TestPasteGetAPI:
66+
"""API tests for paste retrieval endpoints."""
67+
2768
async def test_get_paste_by_id_returns_content(
28-
self, test_client: AsyncClient, authenticated_paste
69+
self, test_client: AsyncClient, authenticated_paste, bypass_headers
2970
):
3071
"""GET /pastes/{id} should return paste content."""
3172
paste_id = authenticated_paste["id"]
3273

33-
response = await test_client.get(f"/pastes/{paste_id}")
74+
response = await test_client.get(f"/pastes/{paste_id}", headers=bypass_headers)
3475

3576
assert response.status_code == 200
3677
data = response.json()
3778

3879
assert data["id"] == paste_id
3980
assert data["title"] == "Test Paste"
4081
assert data["content"] == "This is test content"
82+
83+
async def test_get_paste_returns_404_for_nonexistent(
84+
self, test_client: AsyncClient, bypass_headers
85+
):
86+
"""GET /pastes/{id} should return 404 for non-existent paste."""
87+
import uuid
88+
nonexistent_id = str(uuid.uuid4())
89+
90+
response = await test_client.get(f"/pastes/{nonexistent_id}", headers=bypass_headers)
91+
92+
assert response.status_code == 404
93+
data = response.json()
94+
assert data["error"] == "paste_not_found"
95+
96+
async def test_get_paste_caches_response(
97+
self, test_client: AsyncClient, authenticated_paste, bypass_headers
98+
):
99+
"""GET /pastes/{id} should cache successful responses."""
100+
paste_id = authenticated_paste["id"]
101+
102+
# First request
103+
response1 = await test_client.get(f"/pastes/{paste_id}", headers=bypass_headers)
104+
assert response1.status_code == 200
105+
assert "Cache-Control" in response1.headers
106+
assert "public" in response1.headers["Cache-Control"]
107+
108+
# Second request should hit cache
109+
response2 = await test_client.get(f"/pastes/{paste_id}", headers=bypass_headers)
110+
assert response2.status_code == 200
111+
assert response2.json() == response1.json()
112+
113+
async def test_get_paste_cache_control_headers(
114+
self, test_client: AsyncClient, authenticated_paste, bypass_headers
115+
):
116+
"""GET /pastes/{id} should include proper cache control headers."""
117+
paste_id = authenticated_paste["id"]
118+
119+
response = await test_client.get(f"/pastes/{paste_id}", headers=bypass_headers)
120+
121+
assert response.status_code == 200
122+
assert "Cache-Control" in response.headers
123+
cache_control = response.headers["Cache-Control"]
124+
assert "public" in cache_control
125+
assert "max-age" in cache_control
126+
127+
128+
@pytest.mark.asyncio
129+
class TestPasteEditAPI:
130+
"""API tests for paste editing endpoint."""
131+
132+
async def test_edit_paste_with_valid_token_updates_title(
133+
self, test_client: AsyncClient, authenticated_paste
134+
):
135+
"""PUT /pastes/{id} should update title with valid token."""
136+
paste_id = authenticated_paste["id"]
137+
edit_token = authenticated_paste["edit_token"]
138+
139+
edit_data = {"title": "Updated Title"}
140+
response = await test_client.put(
141+
f"/pastes/{paste_id}",
142+
json=edit_data,
143+
headers={"Authorization": edit_token}
144+
)
145+
146+
assert response.status_code == 200
147+
data = response.json()
148+
assert data["title"] == "Updated Title"
149+
assert data["content"] == "This is test content" # Unchanged
150+
151+
async def test_edit_paste_with_valid_token_updates_content(
152+
self, test_client: AsyncClient, authenticated_paste
153+
):
154+
"""PUT /pastes/{id} should update content with valid token."""
155+
paste_id = authenticated_paste["id"]
156+
edit_token = authenticated_paste["edit_token"]
157+
158+
edit_data = {"content": "Updated content"}
159+
response = await test_client.put(
160+
f"/pastes/{paste_id}",
161+
json=edit_data,
162+
headers={"Authorization": edit_token}
163+
)
164+
165+
assert response.status_code == 200
166+
data = response.json()
167+
assert data["content"] == "Updated content"
168+
assert data["title"] == "Test Paste" # Unchanged
169+
170+
async def test_edit_paste_with_valid_token_updates_language(
171+
self, test_client: AsyncClient, authenticated_paste
172+
):
173+
"""PUT /pastes/{id} should update content language with valid token."""
174+
paste_id = authenticated_paste["id"]
175+
edit_token = authenticated_paste["edit_token"]
176+
177+
edit_data = {"content_language": "plain_text"}
178+
response = await test_client.put(
179+
f"/pastes/{paste_id}",
180+
json=edit_data,
181+
headers={"Authorization": edit_token}
182+
)
183+
184+
assert response.status_code == 200
185+
data = response.json()
186+
assert data["content_language"] == "plain_text"
187+
188+
async def test_edit_paste_with_valid_token_updates_expiration(
189+
self, test_client: AsyncClient, authenticated_paste
190+
):
191+
"""PUT /pastes/{id} should update expiration with valid token."""
192+
paste_id = authenticated_paste["id"]
193+
edit_token = authenticated_paste["edit_token"]
194+
195+
new_expiration = (datetime.now(tz=timezone.utc) + timedelta(days=7)).isoformat()
196+
edit_data = {"expires_at": new_expiration}
197+
response = await test_client.put(
198+
f"/pastes/{paste_id}",
199+
json=edit_data,
200+
headers={"Authorization": edit_token}
201+
)
202+
203+
assert response.status_code == 200
204+
data = response.json()
205+
assert data["expires_at"] is not None
206+
207+
async def test_edit_paste_partial_update(
208+
self, test_client: AsyncClient, authenticated_paste
209+
):
210+
"""PUT /pastes/{id} should support partial updates."""
211+
paste_id = authenticated_paste["id"]
212+
edit_token = authenticated_paste["edit_token"]
213+
214+
# Only update title, leaving other fields unchanged
215+
edit_data = {
216+
"title": "New Title"
217+
}
218+
response = await test_client.put(
219+
f"/pastes/{paste_id}",
220+
json=edit_data,
221+
headers={"Authorization": edit_token}
222+
)
223+
224+
assert response.status_code == 200
225+
data = response.json()
226+
assert data["title"] == "New Title"
227+
assert data["content_language"] == "plain_text" # Unchanged
228+
assert data["content"] == "This is test content" # Unchanged
229+
230+
async def test_edit_paste_returns_404_with_invalid_token(
231+
self, test_client: AsyncClient, authenticated_paste
232+
):
233+
"""PUT /pastes/{id} should return 404 with invalid token."""
234+
paste_id = authenticated_paste["id"]
235+
invalid_token = "invalid_token_12345678901234567890"
236+
237+
edit_data = {"title": "Should Fail"}
238+
response = await test_client.put(
239+
f"/pastes/{paste_id}",
240+
json=edit_data,
241+
headers={"Authorization": invalid_token}
242+
)
243+
244+
assert response.status_code == 404
245+
data = response.json()
246+
assert "detail" in data
247+
assert data["detail"]["error"] == "paste_not_found"
248+
249+
async def test_edit_paste_returns_404_for_nonexistent_paste(
250+
self, test_client: AsyncClient
251+
):
252+
"""PUT /pastes/{id} should return 404 for non-existent paste."""
253+
import uuid
254+
nonexistent_id = str(uuid.uuid4())
255+
fake_token = "a" * 32
256+
257+
edit_data = {"title": "Should Fail"}
258+
response = await test_client.put(
259+
f"/pastes/{nonexistent_id}",
260+
json=edit_data,
261+
headers={"Authorization": fake_token}
262+
)
263+
264+
assert response.status_code == 404
265+
266+
async def test_edit_paste_requires_authorization_header(
267+
self, test_client: AsyncClient, authenticated_paste
268+
):
269+
"""PUT /pastes/{id} should require Authorization header."""
270+
paste_id = authenticated_paste["id"]
271+
272+
edit_data = {"title": "Should Fail"}
273+
response = await test_client.put(
274+
f"/pastes/{paste_id}",
275+
json=edit_data
276+
)
277+
278+
# Should fail with 401 Unauthorized (missing auth header)
279+
assert response.status_code == 401
280+
281+
282+
@pytest.mark.asyncio
283+
class TestPasteDeleteAPI:
284+
"""API tests for paste deletion endpoint."""
285+
286+
async def test_delete_paste_with_valid_token(
287+
self, test_client: AsyncClient, authenticated_paste
288+
):
289+
"""DELETE /pastes/{id} should succeed with valid token."""
290+
paste_id = authenticated_paste["id"]
291+
delete_token = authenticated_paste["delete_token"]
292+
293+
response = await test_client.delete(
294+
f"/pastes/{paste_id}",
295+
headers={"Authorization": delete_token}
296+
)
297+
298+
assert response.status_code == 200
299+
data = response.json()
300+
assert data["message"] == "Paste deleted successfully"
301+
302+
# Verify paste is gone
303+
get_response = await test_client.get(f"/pastes/{paste_id}")
304+
assert get_response.status_code == 404
305+
306+
async def test_delete_paste_returns_404_with_invalid_token(
307+
self, test_client: AsyncClient, authenticated_paste
308+
):
309+
"""DELETE /pastes/{id} should return 404 with invalid token."""
310+
paste_id = authenticated_paste["id"]
311+
invalid_token = "invalid_token_12345678901234567890"
312+
313+
response = await test_client.delete(
314+
f"/pastes/{paste_id}",
315+
headers={"Authorization": invalid_token}
316+
)
317+
318+
assert response.status_code == 404
319+
data = response.json()
320+
assert "detail" in data
321+
assert data["detail"]["error"] == "paste_not_found"
322+
323+
# Verify paste still exists
324+
get_response = await test_client.get(f"/pastes/{paste_id}")
325+
assert get_response.status_code == 200
326+
327+
async def test_delete_paste_returns_404_for_nonexistent_paste(
328+
self, test_client: AsyncClient
329+
):
330+
"""DELETE /pastes/{id} should return 404 for non-existent paste."""
331+
import uuid
332+
nonexistent_id = str(uuid.uuid4())
333+
fake_token = "a" * 32
334+
335+
response = await test_client.delete(
336+
f"/pastes/{nonexistent_id}",
337+
headers={"Authorization": fake_token}
338+
)
339+
340+
assert response.status_code == 404
341+
342+
async def test_delete_paste_requires_authorization_header(
343+
self, test_client: AsyncClient, authenticated_paste
344+
):
345+
"""DELETE /pastes/{id} should require Authorization header."""
346+
paste_id = authenticated_paste["id"]
347+
348+
response = await test_client.delete(f"/pastes/{paste_id}")
349+
350+
# Should fail with 401 Unauthorized (missing auth header)
351+
assert response.status_code == 401

0 commit comments

Comments
 (0)