@@ -142,6 +142,35 @@ async def call_next(_request):
142142 assert record .error_type == "RuntimeError"
143143
144144
145+ def test_structured_logging_formats_record_as_json ():
146+ from src .core .middleware .structured_logging import JsonLogFormatter
147+
148+ record = logging .LogRecord (
149+ name = "test" ,
150+ level = logging .INFO ,
151+ pathname = __file__ ,
152+ lineno = 1 ,
153+ msg = "request completed" ,
154+ args = (),
155+ exc_info = None ,
156+ )
157+ record .method = "GET"
158+ record .path = "/api/v1/todos/"
159+ record .status_code = 200
160+ record .latency_ms = 1.5
161+ record .request_id = "request-1"
162+ record .user_id = "user-1"
163+ record .error_type = None
164+
165+ payload = json .loads (JsonLogFormatter ().format (record ))
166+
167+ assert payload ["message" ] == "request completed"
168+ assert payload ["method" ] == "GET"
169+ assert payload ["path" ] == "/api/v1/todos/"
170+ assert payload ["status_code" ] == 200
171+ assert payload ["request_id" ] == "request-1"
172+
173+
145174def test_admin_router_exposes_liveness_and_readiness ():
146175 app = FastAPI ()
147176 register_admin_router (app )
@@ -269,38 +298,81 @@ async def setex(self, key, ttl, value):
269298 self .values [key ] = value
270299
271300
301+ def build_post_request (body : bytes ):
302+ async def receive ():
303+ return {"type" : "http.request" , "body" : body , "more_body" : False }
304+
305+ return Request (
306+ {
307+ "type" : "http" ,
308+ "method" : "POST" ,
309+ "path" : "/api/v1/todos/" ,
310+ "headers" : [
311+ (b"idempotency-key" , b"create-todo-1" ),
312+ (b"authorization" , b"Bearer token" ),
313+ (b"content-type" , b"application/json" ),
314+ ],
315+ "query_string" : b"" ,
316+ "server" : ("testserver" , 80 ),
317+ "scheme" : "http" ,
318+ "client" : ("testclient" , 50000 ),
319+ },
320+ receive ,
321+ )
322+
323+
272324def test_idempotency_middleware_replays_cached_post_response ():
273325 async def run ():
274326 redis = FakeRedis ()
275327 calls = 0
276- request = Request (
277- {
278- "type" : "http" ,
279- "method" : "POST" ,
280- "path" : "/api/v1/todos/" ,
281- "headers" : [
282- (b"idempotency-key" , b"create-todo-1" ),
283- (b"authorization" , b"Bearer token" ),
284- ],
285- "query_string" : b"" ,
286- "server" : ("testserver" , 80 ),
287- "scheme" : "http" ,
288- "client" : ("testclient" , 50000 ),
289- }
290- )
291-
292328 async def call_next (_request ):
293329 nonlocal calls
294330 calls += 1
295331 return JSONResponse ({"created" : True }, status_code = 201 )
296332
297333 middleware = IdempotencyMiddleware (None , redis = redis )
298- first = await middleware .dispatch (request , call_next )
299- second = await middleware .dispatch (request , call_next )
334+ first = await middleware .dispatch (
335+ build_post_request (b'{"title":"first"}' ),
336+ call_next ,
337+ )
338+ second = await middleware .dispatch (
339+ build_post_request (b'{"title":"first"}' ),
340+ call_next ,
341+ )
300342
301343 assert first .status_code == 201
302344 assert second .status_code == 201
303345 assert json .loads (second .body .decode ()) == {"created" : True }
304346 assert calls == 1
305347
306348 asyncio .run (run ())
349+
350+
351+ def test_idempotency_middleware_rejects_same_key_with_different_body ():
352+ async def run ():
353+ redis = FakeRedis ()
354+ calls = 0
355+
356+ async def call_next (_request ):
357+ nonlocal calls
358+ calls += 1
359+ return JSONResponse ({"created" : True }, status_code = 201 )
360+
361+ middleware = IdempotencyMiddleware (None , redis = redis )
362+ first = await middleware .dispatch (
363+ build_post_request (b'{"title":"first"}' ),
364+ call_next ,
365+ )
366+ second = await middleware .dispatch (
367+ build_post_request (b'{"title":"second"}' ),
368+ call_next ,
369+ )
370+
371+ assert first .status_code == 201
372+ assert second .status_code == 409
373+ assert json .loads (second .body .decode ())["detail" ] == (
374+ "Idempotency-Key was already used with a different request body"
375+ )
376+ assert calls == 1
377+
378+ asyncio .run (run ())
0 commit comments