Skip to content

Commit 82b95b9

Browse files
committed
test: add comprehensive unit tests for ActivePastesCounter, CleanupService, and Config validation
- Add `test_active_pastes_counter.py` to cover initialization, methods, and async behaviors - Add `test_cleanup_service.py` to validate cleanup task management and distributed lock usage - Add `test_config_validation.py` to ensure environment, CORS, compression, and security configs behave as expected - Ensure coverage for edge cases, error handling, and configuration validators
1 parent 5a96c8c commit 82b95b9

File tree

8 files changed

+1776
-61
lines changed

8 files changed

+1776
-61
lines changed

backend/app/api/subroutes/pastes.py

Lines changed: 110 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
from typing import TYPE_CHECKING
33

44
from dependency_injector.wiring import Provide, inject
5-
from fastapi import APIRouter, Depends, HTTPException
5+
from fastapi import APIRouter, Depends
66
from fastapi.params import Security
77
from fastapi.security import APIKeyHeader
88
from pydantic import UUID4
99
from starlette.requests import Request
10-
from starlette.responses import Response
10+
from starlette.responses import PlainTextResponse, Response
1111

1212
from app.api.dto.Error import ErrorResponse
1313
from app.api.dto.paste_dto import (
@@ -19,6 +19,7 @@
1919
)
2020
from app.config import config
2121
from app.containers import Container
22+
from app.exceptions import PasteNotFoundError
2223
from app.ratelimit import create_limit_resolver, get_exempt_key, limiter
2324
from app.services.paste_service import PasteService
2425
from app.utils.LRUMemoryCache import LRUMemoryCache
@@ -49,15 +50,18 @@ def set_cache(cache_instance: "RedisCache | LRUMemoryCache"):
4950
@pastes_route.get(
5051
"/legacy/{paste_id}",
5152
responses={404: {"model": ErrorResponse}, 200: {"model": LegacyPasteResponse}},
53+
summary="Get legacy Hastebin-format paste",
54+
description="Retrieve a paste stored in legacy Hastebin format by its ID.",
5255
)
5356
@limiter.limit(create_limit_resolver(config, "get_paste_legacy"), key_func=get_exempt_key)
5457
@inject
55-
async def get_paste(
58+
async def get_legacy_paste(
5659
request: Request,
5760
paste_id: str,
5861
paste_service: PasteService = Depends(Provide[Container.paste_service]),
5962
):
60-
cached_result = await cache.get(paste_id)
63+
"""Get a legacy Hastebin-format paste by ID."""
64+
cached_result = await cache.get(f"legacy:{paste_id}")
6165
if cached_result:
6266
cache_operations.labels(operation="get", result="hit").inc()
6367
return Response(
@@ -71,24 +75,14 @@ async def get_paste(
7175
cache_operations.labels(operation="get", result="miss").inc()
7276
paste_result = await paste_service.get_legacy_paste_by_name(paste_id)
7377
if not paste_result:
74-
return Response(
75-
ErrorResponse(
76-
error="legacy_paste_not_found",
77-
message=f"Paste {paste_id} not found",
78-
).model_dump_json(),
79-
status_code=404,
80-
headers={
81-
"Content-Type": "application/json",
82-
"Cache-Control": "public, immutable",
83-
},
84-
)
85-
paste_result = paste_result.model_dump_json()
78+
raise PasteNotFoundError(paste_id)
8679

87-
await cache.set(paste_id, paste_result, ttl=config.CACHE_TTL)
80+
paste_json = paste_result.model_dump_json()
81+
await cache.set(f"legacy:{paste_id}", paste_json, ttl=config.CACHE_TTL)
8882
cache_operations.labels(operation="set", result="success").inc()
8983

9084
return Response(
91-
paste_result,
85+
paste_json,
9286
headers={
9387
"Content-Type": "application/json",
9488
"Cache-Control": f"public, max-age={config.CACHE_TTL}",
@@ -99,15 +93,18 @@ async def get_paste(
9993
@pastes_route.get(
10094
"/{paste_id}",
10195
responses={404: {"model": ErrorResponse}, 200: {"model": PasteResponse}},
96+
summary="Get paste by UUID",
97+
description="Retrieve a paste by its UUID identifier.",
10298
)
10399
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=get_exempt_key)
104100
@inject
105-
async def get_paste(
101+
async def get_paste_by_uuid(
106102
request: Request,
107103
paste_id: UUID4,
108104
paste_service: PasteService = Depends(Provide[Container.paste_service]),
109105
):
110-
cached_result = await cache.get(paste_id)
106+
"""Get a paste by its UUID."""
107+
cached_result = await cache.get(str(paste_id))
111108
if cached_result:
112109
cache_operations.labels(operation="get", result="hit").inc()
113110
return Response(
@@ -121,42 +118,94 @@ async def get_paste(
121118
cache_operations.labels(operation="get", result="miss").inc()
122119
paste_result = await paste_service.get_paste_by_id(paste_id)
123120
if not paste_result:
124-
return Response(
125-
ErrorResponse(
126-
error="paste_not_found",
127-
message=f"Paste {paste_id} not found",
128-
).model_dump_json(),
129-
status_code=404,
130-
headers={
131-
"Content-Type": "application/json",
132-
},
133-
)
134-
paste_result = paste_result.model_dump_json()
121+
raise PasteNotFoundError(str(paste_id))
135122

136-
await cache.set(paste_id, paste_result, ttl=config.CACHE_TTL)
123+
paste_json = paste_result.model_dump_json()
124+
await cache.set(str(paste_id), paste_json, ttl=config.CACHE_TTL)
137125
cache_operations.labels(operation="set", result="success").inc()
138126

139127
return Response(
140-
paste_result,
128+
paste_json,
141129
headers={
142130
"Content-Type": "application/json",
143131
"Cache-Control": f"public, max-age={config.CACHE_TTL}",
144132
},
145133
)
146134

147135

148-
@pastes_route.post("", response_model=CreatePasteResponse)
136+
@pastes_route.get(
137+
"/{paste_id}/raw",
138+
response_class=PlainTextResponse,
139+
responses={404: {"model": ErrorResponse}},
140+
summary="Get raw paste content",
141+
description="Retrieve only the raw text content of a paste. Useful for curl/wget users.",
142+
)
143+
@limiter.limit(create_limit_resolver(config, "get_paste"), key_func=get_exempt_key)
144+
@inject
145+
async def get_paste_raw(
146+
request: Request,
147+
paste_id: UUID4,
148+
paste_service: PasteService = Depends(Provide[Container.paste_service]),
149+
):
150+
"""
151+
Get raw paste content as plain text.
152+
153+
This endpoint returns only the paste content without any JSON wrapper,
154+
making it ideal for command-line tools like curl or wget.
155+
156+
Example usage:
157+
curl https://api.devbin.dev/pastes/{paste_id}/raw
158+
"""
159+
# Check cache for raw content
160+
cache_key = f"raw:{paste_id}"
161+
cached_content = await cache.get(cache_key)
162+
if cached_content:
163+
cache_operations.labels(operation="get", result="hit").inc()
164+
return PlainTextResponse(
165+
content=cached_content,
166+
headers={"Cache-Control": f"public, max-age={config.CACHE_TTL}"},
167+
)
168+
169+
cache_operations.labels(operation="get", result="miss").inc()
170+
paste_result = await paste_service.get_paste_by_id(paste_id)
171+
if not paste_result:
172+
raise PasteNotFoundError(str(paste_id))
173+
174+
content = paste_result.content or ""
175+
176+
# Cache the raw content
177+
await cache.set(cache_key, content, ttl=config.CACHE_TTL)
178+
cache_operations.labels(operation="set", result="success").inc()
179+
180+
return PlainTextResponse(
181+
content=content,
182+
headers={"Cache-Control": f"public, max-age={config.CACHE_TTL}"},
183+
)
184+
185+
186+
@pastes_route.post(
187+
"",
188+
response_model=CreatePasteResponse,
189+
summary="Create a new paste",
190+
description="Create a new paste with the provided content and metadata.",
191+
)
149192
@limiter.limit(create_limit_resolver(config, "create_paste"), key_func=get_exempt_key)
150193
@inject
151194
async def create_paste(
152195
request: Request,
153196
create_paste_body: CreatePaste,
154197
paste_service: PasteService = Depends(Provide[Container.paste_service]),
155198
):
199+
"""Create a new paste and return edit/delete tokens."""
156200
return await paste_service.create_paste(create_paste_body, request.state.user_metadata)
157201

158202

159-
@pastes_route.put("/{paste_id}")
203+
@pastes_route.put(
204+
"/{paste_id}",
205+
response_model=PasteResponse,
206+
summary="Edit an existing paste",
207+
description="Update a paste's content or metadata. Requires a valid edit token.",
208+
)
160209
@limiter.limit(create_limit_resolver(config, "edit_paste"), key_func=get_exempt_key)
161210
@inject
162211
async def edit_paste(
@@ -166,22 +215,21 @@ async def edit_paste(
166215
edit_token: str = Security(edit_token_key_header),
167216
paste_service: PasteService = Depends(Provide[Container.paste_service]),
168217
):
218+
"""Edit an existing paste. Requires the edit token returned during creation."""
169219
result = await paste_service.edit_paste(paste_id, edit_paste_body, edit_token)
170220
if not result:
171-
raise HTTPException(
172-
status_code=404,
173-
detail=ErrorResponse(
174-
error="paste_not_found",
175-
message=f"Paste {paste_id} not found",
176-
).model_dump(),
177-
)
178-
# Invalidate cache after successful edit
179-
await cache.delete(paste_id)
180-
cache_operations.labels(operation="delete", result="success").inc()
221+
raise PasteNotFoundError(str(paste_id))
222+
223+
# Invalidate all cache entries for this paste
224+
await _invalidate_paste_cache(paste_id)
181225
return result
182226

183227

184-
@pastes_route.delete("/{paste_id}")
228+
@pastes_route.delete(
229+
"/{paste_id}",
230+
summary="Delete a paste",
231+
description="Permanently delete a paste. Requires a valid delete token.",
232+
)
185233
@limiter.limit(create_limit_resolver(config, "delete_paste"), key_func=get_exempt_key)
186234
@inject
187235
async def delete_paste(
@@ -190,16 +238,22 @@ async def delete_paste(
190238
delete_token: str = Security(delete_token_key_header),
191239
paste_service: PasteService = Depends(Provide[Container.paste_service]),
192240
):
241+
"""Delete a paste. Requires the delete token returned during creation."""
193242
result = await paste_service.delete_paste(paste_id, delete_token)
194243
if not result:
195-
raise HTTPException(
196-
status_code=404,
197-
detail=ErrorResponse(
198-
error="paste_not_found",
199-
message=f"Paste {paste_id} not found",
200-
).model_dump(),
201-
)
202-
# Invalidate cache after successful delete
203-
await cache.delete(paste_id)
204-
cache_operations.labels(operation="delete", result="success").inc()
244+
raise PasteNotFoundError(str(paste_id))
245+
246+
# Invalidate all cache entries for this paste
247+
await _invalidate_paste_cache(paste_id)
205248
return {"message": "Paste deleted successfully"}
249+
250+
251+
async def _invalidate_paste_cache(paste_id: UUID4) -> None:
252+
"""Invalidate all cache entries related to a paste."""
253+
cache_keys = [str(paste_id), f"raw:{paste_id}"]
254+
for key in cache_keys:
255+
try:
256+
await cache.delete(key)
257+
cache_operations.labels(operation="delete", result="success").inc()
258+
except Exception as exc:
259+
logger.warning("Failed to invalidate cache key %s: %s", key, exc)

backend/tests/api/test_paste_routes.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ async def test_get_paste_returns_404_for_nonexistent(self, test_client: AsyncCli
173173

174174
assert response.status_code == 404
175175
data = response.json()
176-
assert data["error"] == "paste_not_found"
176+
assert "error" in data
177+
assert nonexistent_id in data["error"] # Error message contains the paste ID
177178

178179
async def test_get_paste_caches_response(self, test_client: AsyncClient, authenticated_paste, bypass_headers):
179180
"""GET /pastes/{id} should cache successful responses."""
@@ -263,6 +264,57 @@ async def test_get_expired_paste_returns_404(self, test_client: AsyncClient, byp
263264
assert response.status_code == 404
264265

265266

267+
@pytest.mark.asyncio
268+
class TestPasteRawAPI:
269+
"""API tests for raw paste content endpoint."""
270+
271+
async def test_get_raw_paste_returns_plain_text(self, test_client: AsyncClient, authenticated_paste, bypass_headers):
272+
"""GET /pastes/{id}/raw should return raw paste content as plain text."""
273+
paste_id = authenticated_paste["id"]
274+
275+
response = await test_client.get(f"/pastes/{paste_id}/raw", headers=bypass_headers)
276+
277+
assert response.status_code == 200
278+
assert response.headers["content-type"] == "text/plain; charset=utf-8"
279+
assert response.text == "This is test content"
280+
281+
async def test_get_raw_paste_returns_404_for_nonexistent(self, test_client: AsyncClient, bypass_headers):
282+
"""GET /pastes/{id}/raw should return 404 for non-existent paste."""
283+
nonexistent_id = str(uuid.uuid4())
284+
285+
response = await test_client.get(f"/pastes/{nonexistent_id}/raw", headers=bypass_headers)
286+
287+
assert response.status_code == 404
288+
289+
async def test_get_raw_paste_includes_cache_headers(self, test_client: AsyncClient, authenticated_paste, bypass_headers):
290+
"""GET /pastes/{id}/raw should include cache control headers."""
291+
paste_id = authenticated_paste["id"]
292+
293+
response = await test_client.get(f"/pastes/{paste_id}/raw", headers=bypass_headers)
294+
295+
assert response.status_code == 200
296+
assert "Cache-Control" in response.headers
297+
assert "public" in response.headers["Cache-Control"]
298+
assert "max-age" in response.headers["Cache-Control"]
299+
300+
async def test_get_raw_paste_with_unicode_content(self, test_client: AsyncClient, bypass_headers):
301+
"""GET /pastes/{id}/raw should correctly return unicode content."""
302+
paste_data = {
303+
"title": "Unicode Paste",
304+
"content": "Hello 世界! 🎉 Special chars: <>&\"'",
305+
"content_language": "plain_text",
306+
}
307+
308+
create_response = await test_client.post("/pastes", json=paste_data, headers=bypass_headers)
309+
assert create_response.status_code == 200
310+
paste_id = create_response.json()["id"]
311+
312+
response = await test_client.get(f"/pastes/{paste_id}/raw", headers=bypass_headers)
313+
314+
assert response.status_code == 200
315+
assert response.text == paste_data["content"]
316+
317+
266318
@pytest.mark.asyncio
267319
class TestPasteLegacyAPI:
268320
"""API tests for legacy paste endpoint."""
@@ -356,8 +408,8 @@ async def test_edit_paste_returns_404_with_invalid_token(self, test_client: Asyn
356408

357409
assert response.status_code == 404
358410
data = response.json()
359-
assert "detail" in data
360-
assert data["detail"]["error"] == "paste_not_found"
411+
assert "error" in data
412+
assert paste_id in data["error"] # Error message contains the paste ID
361413

362414
async def test_edit_paste_returns_404_for_nonexistent_paste(self, test_client: AsyncClient):
363415
"""PUT /pastes/{id} should return 404 for non-existent paste."""
@@ -410,8 +462,8 @@ async def test_delete_paste_returns_404_with_invalid_token(self, test_client: As
410462

411463
assert response.status_code == 404
412464
data = response.json()
413-
assert "detail" in data
414-
assert data["detail"]["error"] == "paste_not_found"
465+
assert "error" in data
466+
assert paste_id in data["error"] # Error message contains the paste ID
415467

416468
# Verify paste still exists
417469
get_response = await test_client.get(f"/pastes/{paste_id}")

0 commit comments

Comments
 (0)