Coverage for tests/test_pydantic_v1_v2_noneable.py: 100%
142 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-12-04 08:29 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2025-12-04 08:29 +0000
1import sys 1abcdefs
2from typing import Any, List, Union 1abcdefs
4from tests.utils import pydantic_snapshot, skip_module_if_py_gte_314 1abcdefs
6if sys.version_info >= (3, 14): 1abcdefs
7 skip_module_if_py_gte_314() 1s
9from fastapi import FastAPI 1abcdef
10from fastapi._compat.v1 import BaseModel 1abcdef
11from fastapi.testclient import TestClient 1abcdef
12from inline_snapshot import snapshot 1abcdef
13from pydantic import BaseModel as NewBaseModel 1abcdef
16class SubItem(BaseModel): 1abcdef
17 name: str 1abcdef
20class Item(BaseModel): 1abcdef
21 title: str 1abcdef
22 size: int 1abcdef
23 description: Union[str, None] = None 1abcdef
24 sub: SubItem 1abcdef
25 multi: List[SubItem] = [] 1abcdef
28class NewSubItem(NewBaseModel): 1abcdef
29 new_sub_name: str 1abcdef
32class NewItem(NewBaseModel): 1abcdef
33 new_title: str 1abcdef
34 new_size: int 1abcdef
35 new_description: Union[str, None] = None 1abcdef
36 new_sub: NewSubItem 1abcdef
37 new_multi: List[NewSubItem] = [] 1abcdef
40app = FastAPI() 1abcdef
43@app.post("/v1-to-v2/") 1abcdef
44def handle_v1_item_to_v2(data: Item) -> Union[NewItem, None]: 1abcdef
45 if data.size < 0: 1tuvwxyzABCDEFGHIJKLMNOPQ
46 return None 1uyCGKO
47 return NewItem( 1tvwxzABDEFHIJLMNPQ
48 new_title=data.title,
49 new_size=data.size,
50 new_description=data.description,
51 new_sub=NewSubItem(new_sub_name=data.sub.name),
52 new_multi=[NewSubItem(new_sub_name=s.name) for s in data.multi],
53 )
56@app.post("/v1-to-v2/item-filter", response_model=Union[NewItem, None]) 1abcdef
57def handle_v1_item_to_v2_filter(data: Item) -> Any: 1abcdef
58 if data.size < 0: 1RgShTiUjVkWl
59 return None 1RSTUVW
60 result = { 1ghijkl
61 "new_title": data.title,
62 "new_size": data.size,
63 "new_description": data.description,
64 "new_sub": {"new_sub_name": data.sub.name, "new_sub_secret": "sub_hidden"},
65 "new_multi": [
66 {"new_sub_name": s.name, "new_sub_secret": "sub_hidden"} for s in data.multi
67 ],
68 "secret": "hidden_v1_to_v2",
69 }
70 return result 1ghijkl
73@app.post("/v2-to-v1/item") 1abcdef
74def handle_v2_item_to_v1(data: NewItem) -> Union[Item, None]: 1abcdef
75 if data.new_size < 0: 1XYZ0123456789!#$%'()*+,-
76 return None 1Y26!'+
77 return Item( 1XZ01345789#$%()*,-
78 title=data.new_title,
79 size=data.new_size,
80 description=data.new_description,
81 sub=SubItem(name=data.new_sub.new_sub_name),
82 multi=[SubItem(name=s.new_sub_name) for s in data.new_multi],
83 )
86@app.post("/v2-to-v1/item-filter", response_model=Union[Item, None]) 1abcdef
87def handle_v2_item_to_v1_filter(data: NewItem) -> Any: 1abcdef
88 if data.new_size < 0: 1.m/n:o;p=q?r
89 return None 1./:;=?
90 result = { 1mnopqr
91 "title": data.new_title,
92 "size": data.new_size,
93 "description": data.new_description,
94 "sub": {"name": data.new_sub.new_sub_name, "sub_secret": "sub_hidden"},
95 "multi": [
96 {"name": s.new_sub_name, "sub_secret": "sub_hidden"} for s in data.new_multi
97 ],
98 "secret": "hidden_v2_to_v1",
99 }
100 return result 1mnopqr
103client = TestClient(app) 1abcdef
106def test_v1_to_v2_item_success(): 1abcdef
107 response = client.post( 1vzDHLP
108 "/v1-to-v2/",
109 json={
110 "title": "Old Item",
111 "size": 100,
112 "description": "V1 description",
113 "sub": {"name": "V1 Sub"},
114 "multi": [{"name": "M1"}, {"name": "M2"}],
115 },
116 )
117 assert response.status_code == 200, response.text 1vzDHLP
118 assert response.json() == { 1vzDHLP
119 "new_title": "Old Item",
120 "new_size": 100,
121 "new_description": "V1 description",
122 "new_sub": {"new_sub_name": "V1 Sub"},
123 "new_multi": [{"new_sub_name": "M1"}, {"new_sub_name": "M2"}],
124 }
127def test_v1_to_v2_item_returns_none(): 1abcdef
128 response = client.post( 1uyCGKO
129 "/v1-to-v2/",
130 json={"title": "Invalid Item", "size": -10, "sub": {"name": "Sub"}},
131 )
132 assert response.status_code == 200, response.text 1uyCGKO
133 assert response.json() is None 1uyCGKO
136def test_v1_to_v2_item_minimal(): 1abcdef
137 response = client.post( 1txBFJN
138 "/v1-to-v2/", json={"title": "Minimal", "size": 50, "sub": {"name": "MinSub"}}
139 )
140 assert response.status_code == 200, response.text 1txBFJN
141 assert response.json() == { 1txBFJN
142 "new_title": "Minimal",
143 "new_size": 50,
144 "new_description": None,
145 "new_sub": {"new_sub_name": "MinSub"},
146 "new_multi": [],
147 }
150def test_v1_to_v2_item_filter_success(): 1abcdef
151 response = client.post( 1ghijkl
152 "/v1-to-v2/item-filter",
153 json={
154 "title": "Filtered Item",
155 "size": 50,
156 "sub": {"name": "Sub"},
157 "multi": [{"name": "Multi1"}],
158 },
159 )
160 assert response.status_code == 200, response.text 1ghijkl
161 result = response.json() 1ghijkl
162 assert result["new_title"] == "Filtered Item" 1ghijkl
163 assert result["new_size"] == 50 1ghijkl
164 assert result["new_sub"]["new_sub_name"] == "Sub" 1ghijkl
165 assert result["new_multi"][0]["new_sub_name"] == "Multi1" 1ghijkl
166 # Verify secret fields are filtered out
167 assert "secret" not in result 1ghijkl
168 assert "new_sub_secret" not in result["new_sub"] 1ghijkl
169 assert "new_sub_secret" not in result["new_multi"][0] 1ghijkl
172def test_v1_to_v2_item_filter_returns_none(): 1abcdef
173 response = client.post( 1RSTUVW
174 "/v1-to-v2/item-filter",
175 json={"title": "Invalid", "size": -1, "sub": {"name": "Sub"}},
176 )
177 assert response.status_code == 200, response.text 1RSTUVW
178 assert response.json() is None 1RSTUVW
181def test_v2_to_v1_item_success(): 1abcdef
182 response = client.post( 1Z37#(,
183 "/v2-to-v1/item",
184 json={
185 "new_title": "New Item",
186 "new_size": 200,
187 "new_description": "V2 description",
188 "new_sub": {"new_sub_name": "V2 Sub"},
189 "new_multi": [{"new_sub_name": "N1"}, {"new_sub_name": "N2"}],
190 },
191 )
192 assert response.status_code == 200, response.text 1Z37#(,
193 assert response.json() == { 1Z37#(,
194 "title": "New Item",
195 "size": 200,
196 "description": "V2 description",
197 "sub": {"name": "V2 Sub"},
198 "multi": [{"name": "N1"}, {"name": "N2"}],
199 }
202def test_v2_to_v1_item_returns_none(): 1abcdef
203 response = client.post( 1Y26!'+
204 "/v2-to-v1/item",
205 json={
206 "new_title": "Invalid New",
207 "new_size": -5,
208 "new_sub": {"new_sub_name": "NewSub"},
209 },
210 )
211 assert response.status_code == 200, response.text 1Y26!'+
212 assert response.json() is None 1Y26!'+
215def test_v2_to_v1_item_minimal(): 1abcdef
216 response = client.post( 1X159%*
217 "/v2-to-v1/item",
218 json={
219 "new_title": "MinimalNew",
220 "new_size": 75,
221 "new_sub": {"new_sub_name": "MinNewSub"},
222 },
223 )
224 assert response.status_code == 200, response.text 1X159%*
225 assert response.json() == { 1X159%*
226 "title": "MinimalNew",
227 "size": 75,
228 "description": None,
229 "sub": {"name": "MinNewSub"},
230 "multi": [],
231 }
234def test_v2_to_v1_item_filter_success(): 1abcdef
235 response = client.post( 1mnopqr
236 "/v2-to-v1/item-filter",
237 json={
238 "new_title": "Filtered New",
239 "new_size": 75,
240 "new_sub": {"new_sub_name": "NewSub"},
241 "new_multi": [],
242 },
243 )
244 assert response.status_code == 200, response.text 1mnopqr
245 result = response.json() 1mnopqr
246 assert result["title"] == "Filtered New" 1mnopqr
247 assert result["size"] == 75 1mnopqr
248 assert result["sub"]["name"] == "NewSub" 1mnopqr
249 # Verify secret fields are filtered out
250 assert "secret" not in result 1mnopqr
251 assert "sub_secret" not in result["sub"] 1mnopqr
254def test_v2_to_v1_item_filter_returns_none(): 1abcdef
255 response = client.post( 1./:;=?
256 "/v2-to-v1/item-filter",
257 json={
258 "new_title": "Invalid Filtered",
259 "new_size": -100,
260 "new_sub": {"new_sub_name": "Sub"},
261 },
262 )
263 assert response.status_code == 200, response.text 1./:;=?
264 assert response.json() is None 1./:;=?
267def test_v1_to_v2_validation_error(): 1abcdef
268 response = client.post("/v1-to-v2/", json={"title": "Missing fields"}) 2cbdbebfbgbhb
269 assert response.status_code == 422, response.text 2cbdbebfbgbhb
270 assert response.json() == snapshot( 2cbdbebfbgbhb
271 {
272 "detail": [
273 {
274 "loc": ["body", "size"],
275 "msg": "field required",
276 "type": "value_error.missing",
277 },
278 {
279 "loc": ["body", "sub"],
280 "msg": "field required",
281 "type": "value_error.missing",
282 },
283 ]
284 }
285 )
288def test_v1_to_v2_nested_validation_error(): 1abcdef
289 response = client.post( 1@[]^_`
290 "/v1-to-v2/",
291 json={"title": "Bad sub", "size": 100, "sub": {"wrong_field": "value"}},
292 )
293 assert response.status_code == 422, response.text 1@[]^_`
294 error_detail = response.json()["detail"] 1@[]^_`
295 assert len(error_detail) == 1 1@[]^_`
296 assert error_detail[0]["loc"] == ["body", "sub", "name"] 1@[]^_`
299def test_v1_to_v2_type_validation_error(): 1abcdef
300 response = client.post( 2{ | } ~ abbb
301 "/v1-to-v2/",
302 json={"title": "Bad type", "size": "not_a_number", "sub": {"name": "Sub"}},
303 )
304 assert response.status_code == 422, response.text 2{ | } ~ abbb
305 error_detail = response.json()["detail"] 2{ | } ~ abbb
306 assert len(error_detail) == 1 2{ | } ~ abbb
307 assert error_detail[0]["loc"] == ["body", "size"] 2{ | } ~ abbb
310def test_v2_to_v1_validation_error(): 1abcdef
311 response = client.post("/v2-to-v1/item", json={"new_title": "Missing fields"}) 2ibjbkblbmbnb
312 assert response.status_code == 422, response.text 2ibjbkblbmbnb
313 assert response.json() == snapshot( 2ibjbkblbmbnb
314 {
315 "detail": pydantic_snapshot(
316 v2=snapshot(
317 [
318 {
319 "type": "missing",
320 "loc": ["body", "new_size"],
321 "msg": "Field required",
322 "input": {"new_title": "Missing fields"},
323 },
324 {
325 "type": "missing",
326 "loc": ["body", "new_sub"],
327 "msg": "Field required",
328 "input": {"new_title": "Missing fields"},
329 },
330 ]
331 ),
332 v1=snapshot(
333 [
334 {
335 "loc": ["body", "new_size"],
336 "msg": "field required",
337 "type": "value_error.missing",
338 },
339 {
340 "loc": ["body", "new_sub"],
341 "msg": "field required",
342 "type": "value_error.missing",
343 },
344 ]
345 ),
346 )
347 }
348 )
351def test_v2_to_v1_nested_validation_error(): 1abcdef
352 response = client.post( 2obpbqbrbsbtb
353 "/v2-to-v1/item",
354 json={
355 "new_title": "Bad sub",
356 "new_size": 200,
357 "new_sub": {"wrong_field": "value"},
358 },
359 )
360 assert response.status_code == 422, response.text 2obpbqbrbsbtb
361 assert response.json() == snapshot( 2obpbqbrbsbtb
362 {
363 "detail": [
364 pydantic_snapshot(
365 v2=snapshot(
366 {
367 "type": "missing",
368 "loc": ["body", "new_sub", "new_sub_name"],
369 "msg": "Field required",
370 "input": {"wrong_field": "value"},
371 }
372 ),
373 v1=snapshot(
374 {
375 "loc": ["body", "new_sub", "new_sub_name"],
376 "msg": "field required",
377 "type": "value_error.missing",
378 }
379 ),
380 )
381 ]
382 }
383 )
386def test_v2_to_v1_type_validation_error(): 1abcdef
387 response = client.post( 2ubvbwbxbybzb
388 "/v2-to-v1/item",
389 json={
390 "new_title": "Bad type",
391 "new_size": "not_a_number",
392 "new_sub": {"new_sub_name": "Sub"},
393 },
394 )
395 assert response.status_code == 422, response.text 2ubvbwbxbybzb
396 assert response.json() == snapshot( 2ubvbwbxbybzb
397 {
398 "detail": [
399 pydantic_snapshot(
400 v2=snapshot(
401 {
402 "type": "int_parsing",
403 "loc": ["body", "new_size"],
404 "msg": "Input should be a valid integer, unable to parse string as an integer",
405 "input": "not_a_number",
406 }
407 ),
408 v1=snapshot(
409 {
410 "loc": ["body", "new_size"],
411 "msg": "value is not a valid integer",
412 "type": "type_error.integer",
413 }
414 ),
415 )
416 ]
417 }
418 )
421def test_v1_to_v2_with_multi_items(): 1abcdef
422 response = client.post( 1wAEIMQ
423 "/v1-to-v2/",
424 json={
425 "title": "Complex Item",
426 "size": 300,
427 "description": "Item with multiple sub-items",
428 "sub": {"name": "Main Sub"},
429 "multi": [{"name": "Sub1"}, {"name": "Sub2"}, {"name": "Sub3"}],
430 },
431 )
432 assert response.status_code == 200, response.text 1wAEIMQ
433 assert response.json() == snapshot( 1wAEIMQ
434 {
435 "new_title": "Complex Item",
436 "new_size": 300,
437 "new_description": "Item with multiple sub-items",
438 "new_sub": {"new_sub_name": "Main Sub"},
439 "new_multi": [
440 {"new_sub_name": "Sub1"},
441 {"new_sub_name": "Sub2"},
442 {"new_sub_name": "Sub3"},
443 ],
444 }
445 )
448def test_v2_to_v1_with_multi_items(): 1abcdef
449 response = client.post( 1048$)-
450 "/v2-to-v1/item",
451 json={
452 "new_title": "Complex New Item",
453 "new_size": 400,
454 "new_description": "New item with multiple sub-items",
455 "new_sub": {"new_sub_name": "Main New Sub"},
456 "new_multi": [{"new_sub_name": "NewSub1"}, {"new_sub_name": "NewSub2"}],
457 },
458 )
459 assert response.status_code == 200, response.text 1048$)-
460 assert response.json() == snapshot( 1048$)-
461 {
462 "title": "Complex New Item",
463 "size": 400,
464 "description": "New item with multiple sub-items",
465 "sub": {"name": "Main New Sub"},
466 "multi": [{"name": "NewSub1"}, {"name": "NewSub2"}],
467 }
468 )
471def test_openapi_schema(): 1abcdef
472 response = client.get("/openapi.json") 2AbBbCbDbEbFb
473 assert response.status_code == 200, response.text 2AbBbCbDbEbFb
474 assert response.json() == snapshot( 2AbBbCbDbEbFb
475 {
476 "openapi": "3.1.0",
477 "info": {"title": "FastAPI", "version": "0.1.0"},
478 "paths": {
479 "/v1-to-v2/": {
480 "post": {
481 "summary": "Handle V1 Item To V2",
482 "operationId": "handle_v1_item_to_v2_v1_to_v2__post",
483 "requestBody": {
484 "content": {
485 "application/json": {
486 "schema": pydantic_snapshot(
487 v2=snapshot(
488 {
489 "allOf": [
490 {
491 "$ref": "#/components/schemas/Item"
492 }
493 ],
494 "title": "Data",
495 }
496 ),
497 v1=snapshot(
498 {"$ref": "#/components/schemas/Item"}
499 ),
500 )
501 }
502 },
503 "required": True,
504 },
505 "responses": {
506 "200": {
507 "description": "Successful Response",
508 "content": {
509 "application/json": {
510 "schema": pydantic_snapshot(
511 v2=snapshot(
512 {
513 "anyOf": [
514 {
515 "$ref": "#/components/schemas/NewItem"
516 },
517 {"type": "null"},
518 ],
519 "title": "Response Handle V1 Item To V2 V1 To V2 Post",
520 }
521 ),
522 v1=snapshot(
523 {"$ref": "#/components/schemas/NewItem"}
524 ),
525 )
526 }
527 },
528 },
529 "422": {
530 "description": "Validation Error",
531 "content": {
532 "application/json": {
533 "schema": {
534 "$ref": "#/components/schemas/HTTPValidationError"
535 }
536 }
537 },
538 },
539 },
540 }
541 },
542 "/v1-to-v2/item-filter": {
543 "post": {
544 "summary": "Handle V1 Item To V2 Filter",
545 "operationId": "handle_v1_item_to_v2_filter_v1_to_v2_item_filter_post",
546 "requestBody": {
547 "content": {
548 "application/json": {
549 "schema": pydantic_snapshot(
550 v2=snapshot(
551 {
552 "allOf": [
553 {
554 "$ref": "#/components/schemas/Item"
555 }
556 ],
557 "title": "Data",
558 }
559 ),
560 v1=snapshot(
561 {"$ref": "#/components/schemas/Item"}
562 ),
563 )
564 }
565 },
566 "required": True,
567 },
568 "responses": {
569 "200": {
570 "description": "Successful Response",
571 "content": {
572 "application/json": {
573 "schema": pydantic_snapshot(
574 v2=snapshot(
575 {
576 "anyOf": [
577 {
578 "$ref": "#/components/schemas/NewItem"
579 },
580 {"type": "null"},
581 ],
582 "title": "Response Handle V1 Item To V2 Filter V1 To V2 Item Filter Post",
583 }
584 ),
585 v1=snapshot(
586 {"$ref": "#/components/schemas/NewItem"}
587 ),
588 )
589 }
590 },
591 },
592 "422": {
593 "description": "Validation Error",
594 "content": {
595 "application/json": {
596 "schema": {
597 "$ref": "#/components/schemas/HTTPValidationError"
598 }
599 }
600 },
601 },
602 },
603 }
604 },
605 "/v2-to-v1/item": {
606 "post": {
607 "summary": "Handle V2 Item To V1",
608 "operationId": "handle_v2_item_to_v1_v2_to_v1_item_post",
609 "requestBody": {
610 "content": {
611 "application/json": {
612 "schema": {"$ref": "#/components/schemas/NewItem"}
613 }
614 },
615 "required": True,
616 },
617 "responses": {
618 "200": {
619 "description": "Successful Response",
620 "content": {
621 "application/json": {
622 "schema": {"$ref": "#/components/schemas/Item"}
623 }
624 },
625 },
626 "422": {
627 "description": "Validation Error",
628 "content": {
629 "application/json": {
630 "schema": {
631 "$ref": "#/components/schemas/HTTPValidationError"
632 }
633 }
634 },
635 },
636 },
637 }
638 },
639 "/v2-to-v1/item-filter": {
640 "post": {
641 "summary": "Handle V2 Item To V1 Filter",
642 "operationId": "handle_v2_item_to_v1_filter_v2_to_v1_item_filter_post",
643 "requestBody": {
644 "content": {
645 "application/json": {
646 "schema": {"$ref": "#/components/schemas/NewItem"}
647 }
648 },
649 "required": True,
650 },
651 "responses": {
652 "200": {
653 "description": "Successful Response",
654 "content": {
655 "application/json": {
656 "schema": {"$ref": "#/components/schemas/Item"}
657 }
658 },
659 },
660 "422": {
661 "description": "Validation Error",
662 "content": {
663 "application/json": {
664 "schema": {
665 "$ref": "#/components/schemas/HTTPValidationError"
666 }
667 }
668 },
669 },
670 },
671 }
672 },
673 },
674 "components": {
675 "schemas": {
676 "HTTPValidationError": {
677 "properties": {
678 "detail": {
679 "items": {
680 "$ref": "#/components/schemas/ValidationError"
681 },
682 "type": "array",
683 "title": "Detail",
684 }
685 },
686 "type": "object",
687 "title": "HTTPValidationError",
688 },
689 "Item": {
690 "properties": {
691 "title": {"type": "string", "title": "Title"},
692 "size": {"type": "integer", "title": "Size"},
693 "description": {"type": "string", "title": "Description"},
694 "sub": {"$ref": "#/components/schemas/SubItem"},
695 "multi": {
696 "items": {"$ref": "#/components/schemas/SubItem"},
697 "type": "array",
698 "title": "Multi",
699 "default": [],
700 },
701 },
702 "type": "object",
703 "required": ["title", "size", "sub"],
704 "title": "Item",
705 },
706 "NewItem": {
707 "properties": {
708 "new_title": {"type": "string", "title": "New Title"},
709 "new_size": {"type": "integer", "title": "New Size"},
710 "new_description": pydantic_snapshot(
711 v2=snapshot(
712 {
713 "anyOf": [{"type": "string"}, {"type": "null"}],
714 "title": "New Description",
715 }
716 ),
717 v1=snapshot(
718 {"type": "string", "title": "New Description"}
719 ),
720 ),
721 "new_sub": {"$ref": "#/components/schemas/NewSubItem"},
722 "new_multi": {
723 "items": {"$ref": "#/components/schemas/NewSubItem"},
724 "type": "array",
725 "title": "New Multi",
726 "default": [],
727 },
728 },
729 "type": "object",
730 "required": ["new_title", "new_size", "new_sub"],
731 "title": "NewItem",
732 },
733 "NewSubItem": {
734 "properties": {
735 "new_sub_name": {"type": "string", "title": "New Sub Name"}
736 },
737 "type": "object",
738 "required": ["new_sub_name"],
739 "title": "NewSubItem",
740 },
741 "SubItem": {
742 "properties": {"name": {"type": "string", "title": "Name"}},
743 "type": "object",
744 "required": ["name"],
745 "title": "SubItem",
746 },
747 "ValidationError": {
748 "properties": {
749 "loc": {
750 "items": {
751 "anyOf": [{"type": "string"}, {"type": "integer"}]
752 },
753 "type": "array",
754 "title": "Location",
755 },
756 "msg": {"type": "string", "title": "Message"},
757 "type": {"type": "string", "title": "Error Type"},
758 },
759 "type": "object",
760 "required": ["loc", "msg", "type"],
761 "title": "ValidationError",
762 },
763 }
764 },
765 }
766 )