jebin2 commited on
Commit
887aa67
·
1 Parent(s): 7715603

response structure

Browse files
app.py CHANGED
@@ -4,11 +4,13 @@ FastAPI Application - API Gateway with credit management and AI services.
4
  import os
5
  import logging
6
  from contextlib import asynccontextmanager
7
- from fastapi import FastAPI, Request
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import JSONResponse
 
10
 
11
  from core.database import engine, DB_FILENAME
 
12
  from routers import auth, blink, contact, credits, general, gemini, payments, schema
13
  from services.drive_service import DriveService
14
  from services.db_service import init_database, reset_database
@@ -258,13 +260,57 @@ app.include_router(contact.router)
258
  app.include_router(schema.router)
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  @app.exception_handler(Exception)
262
  async def global_exception_handler(request: Request, exc: Exception):
263
- """Global exception handler."""
264
- logger.error(f"Unhandled exception: {exc}")
265
  return JSONResponse(
266
  status_code=500,
267
- content={"detail": "Internal server error"}
 
 
 
268
  )
269
 
270
 
 
4
  import os
5
  import logging
6
  from contextlib import asynccontextmanager
7
+ from fastapi import FastAPI, Request, HTTPException
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import JSONResponse
10
+ from fastapi.exceptions import RequestValidationError
11
 
12
  from core.database import engine, DB_FILENAME
13
+ from core.api_response import APIError, error_response, status_to_error_code, ErrorCode
14
  from routers import auth, blink, contact, credits, general, gemini, payments, schema
15
  from services.drive_service import DriveService
16
  from services.db_service import init_database, reset_database
 
260
  app.include_router(schema.router)
261
 
262
 
263
+ @app.exception_handler(APIError)
264
+ async def api_error_handler(request: Request, exc: APIError):
265
+ """Handle custom APIError exceptions with standardized format."""
266
+ logger.warning(f"API Error: {exc.code} - {exc.message}")
267
+ return JSONResponse(
268
+ status_code=exc.status_code,
269
+ content=error_response(exc.code, exc.message, exc.details)
270
+ )
271
+
272
+
273
+ @app.exception_handler(HTTPException)
274
+ async def http_exception_handler(request: Request, exc: HTTPException):
275
+ """Convert HTTPException to standardized error format."""
276
+ code = status_to_error_code(exc.status_code)
277
+ return JSONResponse(
278
+ status_code=exc.status_code,
279
+ content=error_response(code, str(exc.detail))
280
+ )
281
+
282
+
283
+ @app.exception_handler(RequestValidationError)
284
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
285
+ """Handle Pydantic validation errors with detailed field info."""
286
+ errors = []
287
+ for error in exc.errors():
288
+ errors.append({
289
+ "field": ".".join(str(loc) for loc in error["loc"]),
290
+ "message": error["msg"],
291
+ "type": error["type"]
292
+ })
293
+
294
+ return JSONResponse(
295
+ status_code=422,
296
+ content=error_response(
297
+ ErrorCode.VALIDATION_ERROR,
298
+ "Request validation failed",
299
+ {"errors": errors}
300
+ )
301
+ )
302
+
303
+
304
  @app.exception_handler(Exception)
305
  async def global_exception_handler(request: Request, exc: Exception):
306
+ """Global exception handler for unexpected errors."""
307
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
308
  return JSONResponse(
309
  status_code=500,
310
+ content=error_response(
311
+ ErrorCode.SERVER_ERROR,
312
+ "An unexpected error occurred. Please try again later."
313
+ )
314
  )
315
 
316
 
core/api_response.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API Response - Standardized API response structure.
3
+
4
+ Provides consistent success/error response formats for all API endpoints.
5
+ Clients can use a unified handler for both success and error responses.
6
+
7
+ Success Response:
8
+ {
9
+ "success": true,
10
+ "message": "Operation completed successfully",
11
+ "data": { ... }
12
+ }
13
+
14
+ Error Response:
15
+ {
16
+ "success": false,
17
+ "error": {
18
+ "code": "INSUFFICIENT_CREDITS",
19
+ "message": "You don't have enough credits",
20
+ "details": { ... }
21
+ }
22
+ }
23
+
24
+ Usage:
25
+ # In routers - raising errors
26
+ from core.api_response import APIError, ErrorCode
27
+
28
+ raise APIError(
29
+ code=ErrorCode.INSUFFICIENT_CREDITS,
30
+ message="You need at least 10 credits",
31
+ status_code=402,
32
+ details={"required": 10, "available": 5}
33
+ )
34
+
35
+ # In routers - success response
36
+ from core.api_response import success_response
37
+
38
+ return success_response(
39
+ data={"job_id": "123", "status": "queued"},
40
+ message="Job created successfully"
41
+ )
42
+ """
43
+
44
+ from typing import Optional, Any, Dict
45
+ from pydantic import BaseModel, Field
46
+
47
+
48
+ # =============================================================================
49
+ # Error Codes - Machine-readable error identifiers
50
+ # =============================================================================
51
+
52
+ class ErrorCode:
53
+ """Standard error codes for consistent client handling."""
54
+
55
+ # Authentication errors (401)
56
+ UNAUTHORIZED = "UNAUTHORIZED"
57
+ TOKEN_EXPIRED = "TOKEN_EXPIRED"
58
+ TOKEN_INVALID = "TOKEN_INVALID"
59
+
60
+ # Authorization errors (403)
61
+ FORBIDDEN = "FORBIDDEN"
62
+ ADMIN_REQUIRED = "ADMIN_REQUIRED"
63
+
64
+ # Payment/Credits errors (402)
65
+ INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
66
+ PAYMENT_REQUIRED = "PAYMENT_REQUIRED"
67
+ PAYMENT_FAILED = "PAYMENT_FAILED"
68
+
69
+ # Resource errors (404)
70
+ NOT_FOUND = "NOT_FOUND"
71
+ USER_NOT_FOUND = "USER_NOT_FOUND"
72
+ JOB_NOT_FOUND = "JOB_NOT_FOUND"
73
+
74
+ # Validation errors (400, 422)
75
+ VALIDATION_ERROR = "VALIDATION_ERROR"
76
+ BAD_REQUEST = "BAD_REQUEST"
77
+ INVALID_INPUT = "INVALID_INPUT"
78
+
79
+ # Rate limiting (429)
80
+ RATE_LIMITED = "RATE_LIMITED"
81
+ TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS"
82
+
83
+ # Service errors (5xx)
84
+ SERVER_ERROR = "SERVER_ERROR"
85
+ SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
86
+ EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR"
87
+
88
+ # Business logic errors
89
+ JOB_ALREADY_PROCESSING = "JOB_ALREADY_PROCESSING"
90
+ JOB_EXPIRED = "JOB_EXPIRED"
91
+ OPERATION_NOT_ALLOWED = "OPERATION_NOT_ALLOWED"
92
+
93
+
94
+ # =============================================================================
95
+ # Pydantic Models - For OpenAPI documentation
96
+ # =============================================================================
97
+
98
+ class ErrorDetail(BaseModel):
99
+ """Structured error information."""
100
+ code: str = Field(..., description="Machine-readable error code")
101
+ message: str = Field(..., description="Human-readable error message")
102
+ details: Optional[Dict[str, Any]] = Field(
103
+ None,
104
+ description="Additional context about the error"
105
+ )
106
+
107
+
108
+ class ApiErrorResponse(BaseModel):
109
+ """Standard error response format."""
110
+ success: bool = Field(False, description="Always false for errors")
111
+ error: ErrorDetail = Field(..., description="Error details")
112
+
113
+
114
+ class ApiSuccessResponse(BaseModel):
115
+ """Standard success response format."""
116
+ success: bool = Field(True, description="Always true for success")
117
+ message: Optional[str] = Field(None, description="Optional success message")
118
+ data: Optional[Dict[str, Any]] = Field(None, description="Response payload")
119
+
120
+
121
+ # =============================================================================
122
+ # Custom Exception - For raising API errors
123
+ # =============================================================================
124
+
125
+ class APIError(Exception):
126
+ """
127
+ Custom exception for API errors with structured response.
128
+
129
+ Raise this exception anywhere in your code, and the global exception
130
+ handler will convert it to a standardized JSON response.
131
+
132
+ Example:
133
+ raise APIError(
134
+ code=ErrorCode.INSUFFICIENT_CREDITS,
135
+ message="You need at least 10 credits for this operation",
136
+ status_code=402,
137
+ details={"required": 10, "available": 5}
138
+ )
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ code: str,
144
+ message: str,
145
+ status_code: int = 400,
146
+ details: Optional[Dict[str, Any]] = None
147
+ ):
148
+ self.code = code
149
+ self.message = message
150
+ self.status_code = status_code
151
+ self.details = details
152
+ super().__init__(message)
153
+
154
+ def to_dict(self) -> dict:
155
+ """Convert to response dictionary."""
156
+ return error_response(self.code, self.message, self.details)
157
+
158
+
159
+ # =============================================================================
160
+ # Helper Functions - For building responses
161
+ # =============================================================================
162
+
163
+ def success_response(
164
+ data: Optional[Dict[str, Any]] = None,
165
+ message: Optional[str] = None
166
+ ) -> dict:
167
+ """
168
+ Create a standardized success response.
169
+
170
+ Args:
171
+ data: Response payload (dict)
172
+ message: Optional success message
173
+
174
+ Returns:
175
+ dict: {"success": true, "message": "...", "data": {...}}
176
+
177
+ Example:
178
+ return success_response(
179
+ data={"job_id": "123", "status": "queued"},
180
+ message="Job created successfully"
181
+ )
182
+ """
183
+ response = {"success": True}
184
+ if message:
185
+ response["message"] = message
186
+ if data is not None:
187
+ response["data"] = data
188
+ return response
189
+
190
+
191
+ def error_response(
192
+ code: str,
193
+ message: str,
194
+ details: Optional[Dict[str, Any]] = None
195
+ ) -> dict:
196
+ """
197
+ Create a standardized error response.
198
+
199
+ Args:
200
+ code: Machine-readable error code (use ErrorCode constants)
201
+ message: Human-readable error message
202
+ details: Optional additional context
203
+
204
+ Returns:
205
+ dict: {"success": false, "error": {"code": "...", "message": "...", "details": {...}}}
206
+
207
+ Example:
208
+ return error_response(
209
+ code=ErrorCode.NOT_FOUND,
210
+ message="Job not found",
211
+ details={"job_id": "xyz"}
212
+ )
213
+ """
214
+ error = {
215
+ "code": code,
216
+ "message": message
217
+ }
218
+ if details is not None:
219
+ error["details"] = details
220
+
221
+ return {
222
+ "success": False,
223
+ "error": error
224
+ }
225
+
226
+
227
+ def status_to_error_code(status_code: int) -> str:
228
+ """
229
+ Map HTTP status code to a default error code.
230
+
231
+ Args:
232
+ status_code: HTTP status code
233
+
234
+ Returns:
235
+ str: Corresponding error code
236
+ """
237
+ mapping = {
238
+ 400: ErrorCode.BAD_REQUEST,
239
+ 401: ErrorCode.UNAUTHORIZED,
240
+ 402: ErrorCode.PAYMENT_REQUIRED,
241
+ 403: ErrorCode.FORBIDDEN,
242
+ 404: ErrorCode.NOT_FOUND,
243
+ 422: ErrorCode.VALIDATION_ERROR,
244
+ 429: ErrorCode.RATE_LIMITED,
245
+ 500: ErrorCode.SERVER_ERROR,
246
+ 502: ErrorCode.EXTERNAL_SERVICE_ERROR,
247
+ 503: ErrorCode.SERVICE_UNAVAILABLE,
248
+ }
249
+ return mapping.get(status_code, ErrorCode.SERVER_ERROR)
250
+
251
+
252
+ __all__ = [
253
+ # Error codes
254
+ 'ErrorCode',
255
+
256
+ # Pydantic models
257
+ 'ErrorDetail',
258
+ 'ApiErrorResponse',
259
+ 'ApiSuccessResponse',
260
+
261
+ # Exception
262
+ 'APIError',
263
+
264
+ # Helper functions
265
+ 'success_response',
266
+ 'error_response',
267
+ 'status_to_error_code',
268
+ ]
services/auth_service/middleware.py CHANGED
@@ -14,6 +14,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
14
 
15
  from core.database import async_session_maker
16
  from core.models import User
 
17
  from services.auth_service.config import AuthServiceConfig
18
  from services.auth_service.jwt_provider import (
19
  verify_access_token,
@@ -91,7 +92,10 @@ class AuthMiddleware(BaseServiceMiddleware):
91
  self.log_request(request, "Missing Authorization header (required)")
92
  return JSONResponse(
93
  status_code=status.HTTP_401_UNAUTHORIZED,
94
- content={"detail": "Missing Authorization header"},
 
 
 
95
  headers={"WWW-Authenticate": "Bearer"},
96
  )
97
  else:
@@ -109,7 +113,10 @@ class AuthMiddleware(BaseServiceMiddleware):
109
  self.log_request(request, "Invalid Authorization header format")
110
  return JSONResponse(
111
  status_code=status.HTTP_401_UNAUTHORIZED,
112
- content={"detail": "Invalid Authorization header format. Use: Bearer <token>"},
 
 
 
113
  headers={"WWW-Authenticate": "Bearer"},
114
  )
115
  else:
@@ -131,7 +138,10 @@ class AuthMiddleware(BaseServiceMiddleware):
131
  self.log_request(request, "Token expired")
132
  return JSONResponse(
133
  status_code=status.HTTP_401_UNAUTHORIZED,
134
- content={"detail": "Token has expired. Please sign in again."},
 
 
 
135
  headers={"WWW-Authenticate": "Bearer"},
136
  )
137
  else:
@@ -146,7 +156,10 @@ class AuthMiddleware(BaseServiceMiddleware):
146
  self.log_error(request, f"Token verification failed: {e}")
147
  return JSONResponse(
148
  status_code=status.HTTP_401_UNAUTHORIZED,
149
- content={"detail": f"Invalid token: {str(e)}"},
 
 
 
150
  headers={"WWW-Authenticate": "Bearer"},
151
  )
152
  else:
@@ -173,7 +186,10 @@ class AuthMiddleware(BaseServiceMiddleware):
173
  self.log_request(request, "User not found or inactive")
174
  return JSONResponse(
175
  status_code=status.HTTP_401_UNAUTHORIZED,
176
- content={"detail": "User not found or inactive"},
 
 
 
177
  )
178
  else:
179
  # Optional auth, user not found
@@ -183,7 +199,6 @@ class AuthMiddleware(BaseServiceMiddleware):
183
  response = await call_next(request)
184
  return response
185
 
186
- # Validate token version
187
  if payload.token_version < user.token_version:
188
  if requires_auth:
189
  self.log_request(
@@ -192,7 +207,10 @@ class AuthMiddleware(BaseServiceMiddleware):
192
  )
193
  return JSONResponse(
194
  status_code=status.HTTP_401_UNAUTHORIZED,
195
- content={"detail": "Token has been invalidated. Please sign in again."},
 
 
 
196
  headers={"WWW-Authenticate": "Bearer"},
197
  )
198
  else:
 
14
 
15
  from core.database import async_session_maker
16
  from core.models import User
17
+ from core.api_response import error_response, ErrorCode
18
  from services.auth_service.config import AuthServiceConfig
19
  from services.auth_service.jwt_provider import (
20
  verify_access_token,
 
92
  self.log_request(request, "Missing Authorization header (required)")
93
  return JSONResponse(
94
  status_code=status.HTTP_401_UNAUTHORIZED,
95
+ content=error_response(
96
+ ErrorCode.UNAUTHORIZED,
97
+ "Missing Authorization header"
98
+ ),
99
  headers={"WWW-Authenticate": "Bearer"},
100
  )
101
  else:
 
113
  self.log_request(request, "Invalid Authorization header format")
114
  return JSONResponse(
115
  status_code=status.HTTP_401_UNAUTHORIZED,
116
+ content=error_response(
117
+ ErrorCode.TOKEN_INVALID,
118
+ "Invalid Authorization header format. Use: Bearer <token>"
119
+ ),
120
  headers={"WWW-Authenticate": "Bearer"},
121
  )
122
  else:
 
138
  self.log_request(request, "Token expired")
139
  return JSONResponse(
140
  status_code=status.HTTP_401_UNAUTHORIZED,
141
+ content=error_response(
142
+ ErrorCode.TOKEN_EXPIRED,
143
+ "Token has expired. Please sign in again."
144
+ ),
145
  headers={"WWW-Authenticate": "Bearer"},
146
  )
147
  else:
 
156
  self.log_error(request, f"Token verification failed: {e}")
157
  return JSONResponse(
158
  status_code=status.HTTP_401_UNAUTHORIZED,
159
+ content=error_response(
160
+ ErrorCode.TOKEN_INVALID,
161
+ f"Invalid token: {str(e)}"
162
+ ),
163
  headers={"WWW-Authenticate": "Bearer"},
164
  )
165
  else:
 
186
  self.log_request(request, "User not found or inactive")
187
  return JSONResponse(
188
  status_code=status.HTTP_401_UNAUTHORIZED,
189
+ content=error_response(
190
+ ErrorCode.USER_NOT_FOUND,
191
+ "User not found or inactive"
192
+ ),
193
  )
194
  else:
195
  # Optional auth, user not found
 
199
  response = await call_next(request)
200
  return response
201
 
 
202
  if payload.token_version < user.token_version:
203
  if requires_auth:
204
  self.log_request(
 
207
  )
208
  return JSONResponse(
209
  status_code=status.HTTP_401_UNAUTHORIZED,
210
+ content=error_response(
211
+ ErrorCode.TOKEN_INVALID,
212
+ "Token has been invalidated. Please sign in again."
213
+ ),
214
  headers={"WWW-Authenticate": "Bearer"},
215
  )
216
  else:
services/credit_service/middleware.py CHANGED
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse, Response
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
 
13
  from core.database import async_session_maker
 
14
  from services.credit_service.config import CreditServiceConfig
15
  from services.credit_service.transaction_manager import (
16
  CreditTransactionManager,
@@ -58,7 +59,10 @@ class CreditMiddleware(BaseServiceMiddleware):
58
  if not user:
59
  return JSONResponse(
60
  status_code=status.HTTP_401_UNAUTHORIZED,
61
- content={"detail": "Authentication required for this endpoint"}
 
 
 
62
  )
63
 
64
  # Reserve credits
@@ -95,18 +99,24 @@ class CreditMiddleware(BaseServiceMiddleware):
95
  await db.rollback()
96
  return JSONResponse(
97
  status_code=status.HTTP_402_PAYMENT_REQUIRED,
98
- content={
99
- "detail": f"Insufficient credits. Required: {credit_cost}, Available: {user.credits}",
100
- "credits_required": credit_cost,
101
- "credits_available": user.credits
102
- }
 
 
 
103
  )
104
  except Exception as e:
105
  await db.rollback()
106
  logger.error(f"Credit reservation failed: {e}", exc_info=True)
107
  return JSONResponse(
108
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
109
- content={"detail": "Failed to reserve credits"}
 
 
 
110
  )
111
 
112
 
 
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
 
13
  from core.database import async_session_maker
14
+ from core.api_response import error_response, ErrorCode
15
  from services.credit_service.config import CreditServiceConfig
16
  from services.credit_service.transaction_manager import (
17
  CreditTransactionManager,
 
59
  if not user:
60
  return JSONResponse(
61
  status_code=status.HTTP_401_UNAUTHORIZED,
62
+ content=error_response(
63
+ ErrorCode.UNAUTHORIZED,
64
+ "Authentication required for this endpoint"
65
+ )
66
  )
67
 
68
  # Reserve credits
 
99
  await db.rollback()
100
  return JSONResponse(
101
  status_code=status.HTTP_402_PAYMENT_REQUIRED,
102
+ content=error_response(
103
+ ErrorCode.INSUFFICIENT_CREDITS,
104
+ f"Insufficient credits. Required: {credit_cost}, Available: {user.credits}",
105
+ {
106
+ "credits_required": credit_cost,
107
+ "credits_available": user.credits
108
+ }
109
+ )
110
  )
111
  except Exception as e:
112
  await db.rollback()
113
  logger.error(f"Credit reservation failed: {e}", exc_info=True)
114
  return JSONResponse(
115
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
116
+ content=error_response(
117
+ ErrorCode.SERVER_ERROR,
118
+ "Failed to reserve credits"
119
+ )
120
  )
121
 
122
 
tests/test_api_response.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for API Response module.
3
+
4
+ Tests the standardized API response format, exception handlers,
5
+ and helper functions.
6
+ """
7
+
8
+ import pytest
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.testclient import TestClient
11
+ from fastapi.exceptions import RequestValidationError
12
+ from pydantic import BaseModel, Field
13
+
14
+ from core.api_response import (
15
+ ErrorCode,
16
+ ErrorDetail,
17
+ ApiErrorResponse,
18
+ ApiSuccessResponse,
19
+ APIError,
20
+ success_response,
21
+ error_response,
22
+ status_to_error_code,
23
+ )
24
+
25
+
26
+ # =============================================================================
27
+ # Unit Tests - Helper Functions
28
+ # =============================================================================
29
+
30
+ class TestSuccessResponse:
31
+ """Test success_response helper function."""
32
+
33
+ def test_basic_success(self):
34
+ """success_response returns correct format."""
35
+ result = success_response()
36
+ assert result == {"success": True}
37
+
38
+ def test_success_with_data(self):
39
+ """success_response includes data when provided."""
40
+ data = {"job_id": "123", "status": "queued"}
41
+ result = success_response(data=data)
42
+
43
+ assert result["success"] is True
44
+ assert result["data"] == data
45
+ assert "message" not in result
46
+
47
+ def test_success_with_message(self):
48
+ """success_response includes message when provided."""
49
+ result = success_response(message="Job created")
50
+
51
+ assert result["success"] is True
52
+ assert result["message"] == "Job created"
53
+ assert "data" not in result
54
+
55
+ def test_success_with_data_and_message(self):
56
+ """success_response includes both data and message."""
57
+ data = {"id": 1}
58
+ result = success_response(data=data, message="Success!")
59
+
60
+ assert result["success"] is True
61
+ assert result["data"] == data
62
+ assert result["message"] == "Success!"
63
+
64
+
65
+ class TestErrorResponse:
66
+ """Test error_response helper function."""
67
+
68
+ def test_basic_error(self):
69
+ """error_response returns correct format."""
70
+ result = error_response(
71
+ code=ErrorCode.NOT_FOUND,
72
+ message="Job not found"
73
+ )
74
+
75
+ assert result["success"] is False
76
+ assert result["error"]["code"] == "NOT_FOUND"
77
+ assert result["error"]["message"] == "Job not found"
78
+ assert "details" not in result["error"]
79
+
80
+ def test_error_with_details(self):
81
+ """error_response includes details when provided."""
82
+ result = error_response(
83
+ code=ErrorCode.INSUFFICIENT_CREDITS,
84
+ message="Not enough credits",
85
+ details={"required": 10, "available": 5}
86
+ )
87
+
88
+ assert result["success"] is False
89
+ assert result["error"]["code"] == "INSUFFICIENT_CREDITS"
90
+ assert result["error"]["details"]["required"] == 10
91
+ assert result["error"]["details"]["available"] == 5
92
+
93
+
94
+ class TestStatusToErrorCode:
95
+ """Test status_to_error_code mapping function."""
96
+
97
+ def test_401_maps_to_unauthorized(self):
98
+ assert status_to_error_code(401) == ErrorCode.UNAUTHORIZED
99
+
100
+ def test_402_maps_to_payment_required(self):
101
+ assert status_to_error_code(402) == ErrorCode.PAYMENT_REQUIRED
102
+
103
+ def test_404_maps_to_not_found(self):
104
+ assert status_to_error_code(404) == ErrorCode.NOT_FOUND
105
+
106
+ def test_429_maps_to_rate_limited(self):
107
+ assert status_to_error_code(429) == ErrorCode.RATE_LIMITED
108
+
109
+ def test_500_maps_to_server_error(self):
110
+ assert status_to_error_code(500) == ErrorCode.SERVER_ERROR
111
+
112
+ def test_unknown_maps_to_server_error(self):
113
+ """Unknown status codes default to SERVER_ERROR."""
114
+ assert status_to_error_code(418) == ErrorCode.SERVER_ERROR
115
+
116
+
117
+ # =============================================================================
118
+ # Unit Tests - APIError Exception
119
+ # =============================================================================
120
+
121
+ class TestAPIError:
122
+ """Test APIError custom exception."""
123
+
124
+ def test_api_error_attributes(self):
125
+ """APIError stores all attributes correctly."""
126
+ error = APIError(
127
+ code=ErrorCode.INSUFFICIENT_CREDITS,
128
+ message="Need more credits",
129
+ status_code=402,
130
+ details={"needed": 10}
131
+ )
132
+
133
+ assert error.code == ErrorCode.INSUFFICIENT_CREDITS
134
+ assert error.message == "Need more credits"
135
+ assert error.status_code == 402
136
+ assert error.details == {"needed": 10}
137
+
138
+ def test_api_error_default_status(self):
139
+ """APIError defaults to 400 status code."""
140
+ error = APIError(
141
+ code=ErrorCode.BAD_REQUEST,
142
+ message="Invalid input"
143
+ )
144
+
145
+ assert error.status_code == 400
146
+ assert error.details is None
147
+
148
+ def test_api_error_to_dict(self):
149
+ """APIError.to_dict() returns correct format."""
150
+ error = APIError(
151
+ code=ErrorCode.NOT_FOUND,
152
+ message="Resource not found",
153
+ details={"id": "xyz"}
154
+ )
155
+
156
+ result = error.to_dict()
157
+ assert result["success"] is False
158
+ assert result["error"]["code"] == "NOT_FOUND"
159
+ assert result["error"]["message"] == "Resource not found"
160
+ assert result["error"]["details"]["id"] == "xyz"
161
+
162
+ def test_api_error_is_exception(self):
163
+ """APIError can be raised and caught as Exception."""
164
+ with pytest.raises(APIError) as exc_info:
165
+ raise APIError(code="TEST", message="Test error")
166
+
167
+ assert exc_info.value.code == "TEST"
168
+ assert str(exc_info.value) == "Test error"
169
+
170
+
171
+ # =============================================================================
172
+ # Unit Tests - Pydantic Models
173
+ # =============================================================================
174
+
175
+ class TestPydanticModels:
176
+ """Test Pydantic response models."""
177
+
178
+ def test_error_detail_model(self):
179
+ """ErrorDetail model validates correctly."""
180
+ detail = ErrorDetail(
181
+ code="TEST_ERROR",
182
+ message="Test message",
183
+ details={"key": "value"}
184
+ )
185
+
186
+ assert detail.code == "TEST_ERROR"
187
+ assert detail.message == "Test message"
188
+ assert detail.details == {"key": "value"}
189
+
190
+ def test_error_detail_optional_details(self):
191
+ """ErrorDetail allows missing details."""
192
+ detail = ErrorDetail(code="TEST", message="Test")
193
+ assert detail.details is None
194
+
195
+ def test_api_success_response_model(self):
196
+ """ApiSuccessResponse model validates correctly."""
197
+ response = ApiSuccessResponse(
198
+ success=True,
199
+ message="Done",
200
+ data={"result": 42}
201
+ )
202
+
203
+ assert response.success is True
204
+ assert response.message == "Done"
205
+ assert response.data == {"result": 42}
206
+
207
+ def test_api_error_response_model(self):
208
+ """ApiErrorResponse model validates correctly."""
209
+ response = ApiErrorResponse(
210
+ success=False,
211
+ error=ErrorDetail(code="ERR", message="Error occurred")
212
+ )
213
+
214
+ assert response.success is False
215
+ assert response.error.code == "ERR"
216
+
217
+
218
+ # =============================================================================
219
+ # Integration Tests - Exception Handlers
220
+ # =============================================================================
221
+
222
+ class TestExceptionHandlers:
223
+ """Test FastAPI exception handlers produce correct responses."""
224
+
225
+ @pytest.fixture
226
+ def client(self):
227
+ """Create test client with exception handlers."""
228
+ from app import app
229
+ return TestClient(app, raise_server_exceptions=False)
230
+
231
+ def test_http_exception_format(self, client):
232
+ """HTTPException returns standardized format."""
233
+ # Access protected route without auth
234
+ response = client.get("/gemini/jobs")
235
+
236
+ assert response.status_code == 401
237
+ data = response.json()
238
+ assert data["success"] is False
239
+ assert "error" in data
240
+ assert data["error"]["code"] == "UNAUTHORIZED"
241
+ assert "message" in data["error"]
242
+
243
+ def test_404_error_format(self, client):
244
+ """404 errors return standardized format."""
245
+ response = client.get("/nonexistent-endpoint")
246
+
247
+ assert response.status_code == 404
248
+ data = response.json()
249
+ assert data["success"] is False
250
+ assert data["error"]["code"] == "NOT_FOUND"
251
+
252
+
253
+ class TestErrorCodeConstants:
254
+ """Test ErrorCode constants are defined correctly."""
255
+
256
+ def test_auth_error_codes(self):
257
+ assert ErrorCode.UNAUTHORIZED == "UNAUTHORIZED"
258
+ assert ErrorCode.TOKEN_EXPIRED == "TOKEN_EXPIRED"
259
+ assert ErrorCode.FORBIDDEN == "FORBIDDEN"
260
+
261
+ def test_payment_error_codes(self):
262
+ assert ErrorCode.INSUFFICIENT_CREDITS == "INSUFFICIENT_CREDITS"
263
+ assert ErrorCode.PAYMENT_REQUIRED == "PAYMENT_REQUIRED"
264
+
265
+ def test_validation_error_codes(self):
266
+ assert ErrorCode.VALIDATION_ERROR == "VALIDATION_ERROR"
267
+ assert ErrorCode.BAD_REQUEST == "BAD_REQUEST"
268
+
269
+ def test_server_error_codes(self):
270
+ assert ErrorCode.SERVER_ERROR == "SERVER_ERROR"
271
+ assert ErrorCode.SERVICE_UNAVAILABLE == "SERVICE_UNAVAILABLE"