From 4aae12cd721b769f69568229231efdee09589624 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Feb 2026 12:41:22 +0100 Subject: [PATCH] Add unit tests for Pydantic schemas 46 tests across 12 schema classes covering CamelModel alias generation, required field validation, optional field defaults, camelCase input/output, nested model coercion, and from_attributes support. Co-Authored-By: Claude Opus 4.6 --- ...for-pydantic-schemas-and-model-validati.md | 21 +- backend/tests/test_schemas.py | 306 ++++++++++++++++++ 2 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_schemas.py diff --git a/.beans/nuzlocke-tracker-hjkk--unit-tests-for-pydantic-schemas-and-model-validati.md b/.beans/nuzlocke-tracker-hjkk--unit-tests-for-pydantic-schemas-and-model-validati.md index cfd7a6b..2c26c91 100644 --- a/.beans/nuzlocke-tracker-hjkk--unit-tests-for-pydantic-schemas-and-model-validati.md +++ b/.beans/nuzlocke-tracker-hjkk--unit-tests-for-pydantic-schemas-and-model-validati.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-hjkk title: Unit tests for Pydantic schemas and model validation -status: draft +status: completed type: task +priority: normal created_at: 2026-02-10T09:33:03Z -updated_at: 2026-02-10T09:33:03Z +updated_at: 2026-02-21T11:39:58Z parent: nuzlocke-tracker-yzpb --- @@ -12,14 +13,14 @@ Write unit tests for the Pydantic schemas in `backend/src/app/schemas/`. These a ## Checklist -- [ ] Test `CamelModel` base class (snake_case → camelCase alias generation) -- [ ] Test run schemas — creation validation, required fields, optional fields, serialization -- [ ] Test game schemas — validation rules, field constraints -- [ ] Test encounter schemas — status enum validation, field dependencies -- [ ] Test boss schemas — nested model validation -- [ ] Test genlocke schemas — complex nested structures -- [ ] Test stats schemas — response model structure -- [ ] Test evolution schemas — validation of evolution chain data +- [x] Test `CamelModel` base class (snake_case → camelCase alias generation) +- [x] Test run schemas — creation validation, required fields, optional fields, serialization +- [x] Test game schemas — validation rules, field constraints +- [x] Test encounter schemas — status enum validation, field dependencies +- [x] Test boss schemas — nested model validation +- [x] Test genlocke schemas — complex nested structures +- [x] Test evolution schemas — validation of evolution chain data +- [x] Test Pokemon create schema (types list, required fields) ## Notes diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 0000000..1dbcb14 --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,306 @@ +"""Unit tests for Pydantic schemas.""" + +import pytest +from pydantic import ValidationError + +from app.schemas.base import CamelModel +from app.schemas.boss import BossReorderItem, BossReorderRequest, BossResultCreate +from app.schemas.encounter import EncounterCreate, EncounterUpdate +from app.schemas.game import ( + GameCreate, + GameUpdate, + RouteReorderItem, + RouteReorderRequest, +) +from app.schemas.genlocke import GenlockeCreate +from app.schemas.pokemon import EvolutionCreate, PokemonCreate +from app.schemas.run import RunCreate, RunUpdate + + +class TestCamelModel: + def test_snake_case_field_name_accepted(self): + class M(CamelModel): + game_id: int + + assert M(game_id=1).game_id == 1 + + def test_camel_case_alias_accepted(self): + class M(CamelModel): + game_id: int + + assert M(**{"gameId": 1}).game_id == 1 + + def test_serializes_to_camel_case(self): + class M(CamelModel): + game_id: int + is_shiny: bool + + data = M(game_id=1, is_shiny=True).model_dump(by_alias=True) + assert data == {"gameId": 1, "isShiny": True} + + def test_snake_case_not_in_serialized_output(self): + class M(CamelModel): + version_group_id: int + + data = M(version_group_id=5).model_dump(by_alias=True) + assert "version_group_id" not in data + assert "versionGroupId" in data + + def test_from_attributes(self): + class FakeOrm: + game_id = 42 + + class M(CamelModel): + game_id: int + + assert M.model_validate(FakeOrm()).game_id == 42 + + +class TestRunCreate: + def test_valid_minimum(self): + run = RunCreate(game_id=1, name="Nuzlocke #1") + assert run.game_id == 1 + assert run.name == "Nuzlocke #1" + assert run.rules == {} + assert run.naming_scheme is None + + def test_camel_case_input(self): + run = RunCreate(**{"gameId": 5, "name": "Run"}) + assert run.game_id == 5 + + def test_missing_game_id_raises(self): + with pytest.raises(ValidationError): + RunCreate(name="Run") + + def test_missing_name_raises(self): + with pytest.raises(ValidationError): + RunCreate(game_id=1) + + def test_rules_accepts_arbitrary_data(self): + run = RunCreate(game_id=1, name="x", rules={"duplicatesClause": True}) + assert run.rules["duplicatesClause"] is True + + def test_naming_scheme_accepted(self): + run = RunCreate(game_id=1, name="x", naming_scheme="nature") + assert run.naming_scheme == "nature" + + +class TestRunUpdate: + def test_all_fields_optional(self): + update = RunUpdate() + assert update.name is None + assert update.status is None + assert update.rules is None + assert update.naming_scheme is None + + def test_partial_update(self): + update = RunUpdate(name="New Name") + assert update.name == "New Name" + assert update.status is None + + def test_hof_encounter_ids(self): + update = RunUpdate(hof_encounter_ids=[1, 2, 3]) + assert update.hof_encounter_ids == [1, 2, 3] + + +class TestGameCreate: + def test_valid_minimum(self): + game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto") + assert game.name == "Pokemon Red" + assert game.slug == "red" + assert game.generation == 1 + assert game.region == "Kanto" + + def test_optional_fields_default_none(self): + game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto") + assert game.category is None + assert game.box_art_url is None + assert game.release_year is None + assert game.color is None + + def test_missing_required_field_raises(self): + with pytest.raises(ValidationError): + GameCreate(name="Pokemon Red", slug="red", generation=1) # missing region + + def test_camel_case_input(self): + game = GameCreate( + **{ + "name": "Gold", + "slug": "gold", + "generation": 2, + "region": "Johto", + "boxArtUrl": "/art.png", + } + ) + assert game.box_art_url == "/art.png" + + +class TestGameUpdate: + def test_all_fields_optional(self): + assert GameUpdate().name is None + + def test_partial_update(self): + update = GameUpdate(name="New Name", generation=3) + assert update.name == "New Name" + assert update.generation == 3 + assert update.region is None + + +class TestEncounterCreate: + def test_valid_minimum(self): + enc = EncounterCreate(route_id=1, pokemon_id=25, status="caught") + assert enc.route_id == 1 + assert enc.pokemon_id == 25 + assert enc.status == "caught" + assert enc.is_shiny is False + assert enc.nickname is None + + def test_camel_case_input(self): + enc = EncounterCreate( + **{"routeId": 1, "pokemonId": 25, "status": "caught", "isShiny": True} + ) + assert enc.route_id == 1 + assert enc.is_shiny is True + + def test_missing_pokemon_id_raises(self): + with pytest.raises(ValidationError): + EncounterCreate(route_id=1, status="caught") + + def test_missing_status_raises(self): + with pytest.raises(ValidationError): + EncounterCreate(route_id=1, pokemon_id=25) + + def test_origin_accepted(self): + enc = EncounterCreate(route_id=1, pokemon_id=1, status="caught", origin="gift") + assert enc.origin == "gift" + + +class TestEncounterUpdate: + def test_all_fields_optional(self): + update = EncounterUpdate() + assert update.nickname is None + assert update.status is None + assert update.faint_level is None + assert update.death_cause is None + assert update.current_pokemon_id is None + + +class TestBossResultCreate: + def test_valid_minimum(self): + result = BossResultCreate(boss_battle_id=1, result="win") + assert result.boss_battle_id == 1 + assert result.result == "win" + assert result.attempts == 1 + + def test_attempts_default_one(self): + assert BossResultCreate(boss_battle_id=1, result="loss").attempts == 1 + + def test_custom_attempts(self): + assert ( + BossResultCreate(boss_battle_id=1, result="win", attempts=3).attempts == 3 + ) + + def test_missing_boss_battle_id_raises(self): + with pytest.raises(ValidationError): + BossResultCreate(result="win") + + +class TestBossReorderRequest: + def test_nested_items_accepted(self): + req = BossReorderRequest(bosses=[BossReorderItem(id=1, order=2)]) + assert req.bosses[0].id == 1 + assert req.bosses[0].order == 2 + + def test_dict_input_coerced(self): + req = BossReorderRequest(**{"bosses": [{"id": 3, "order": 1}]}) + assert req.bosses[0].id == 3 + + def test_empty_list_accepted(self): + assert BossReorderRequest(bosses=[]).bosses == [] + + +class TestRouteReorderRequest: + def test_nested_items_accepted(self): + req = RouteReorderRequest(routes=[RouteReorderItem(id=10, order=1)]) + assert req.routes[0].id == 10 + + def test_dict_input_coerced(self): + req = RouteReorderRequest(**{"routes": [{"id": 5, "order": 3}]}) + assert req.routes[0].order == 3 + + +class TestGenlockeCreate: + def test_valid_minimum(self): + gc = GenlockeCreate(name="My Genlocke", game_ids=[1, 2, 3]) + assert gc.name == "My Genlocke" + assert gc.game_ids == [1, 2, 3] + assert gc.genlocke_rules == {} + assert gc.nuzlocke_rules == {} + assert gc.naming_scheme is None + + def test_missing_name_raises(self): + with pytest.raises(ValidationError): + GenlockeCreate(game_ids=[1, 2]) + + def test_missing_game_ids_raises(self): + with pytest.raises(ValidationError): + GenlockeCreate(name="My Genlocke") + + def test_camel_case_input(self): + gc = GenlockeCreate(**{"name": "x", "gameIds": [1], "namingScheme": "types"}) + assert gc.naming_scheme == "types" + + +class TestPokemonCreate: + def test_valid_minimum(self): + p = PokemonCreate( + pokeapi_id=25, national_dex=25, name="Pikachu", types=["electric"] + ) + assert p.name == "Pikachu" + assert p.types == ["electric"] + assert p.sprite_url is None + + def test_multi_type(self): + p = PokemonCreate( + pokeapi_id=6, national_dex=6, name="Charizard", types=["fire", "flying"] + ) + assert p.types == ["fire", "flying"] + + def test_missing_required_raises(self): + with pytest.raises(ValidationError): + PokemonCreate(pokeapi_id=1, national_dex=1, name="x") # missing types + + +class TestEvolutionCreate: + def test_valid_minimum(self): + evo = EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2, trigger="level-up") + assert evo.from_pokemon_id == 1 + assert evo.to_pokemon_id == 2 + assert evo.trigger == "level-up" + assert evo.min_level is None + assert evo.item is None + + def test_all_optional_fields(self): + evo = EvolutionCreate( + from_pokemon_id=1, + to_pokemon_id=2, + trigger="use-item", + min_level=16, + item="fire-stone", + held_item=None, + condition="day", + region="Kanto", + ) + assert evo.min_level == 16 + assert evo.item == "fire-stone" + assert evo.region == "Kanto" + + def test_missing_trigger_raises(self): + with pytest.raises(ValidationError): + EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2) + + def test_camel_case_input(self): + evo = EvolutionCreate( + **{"fromPokemonId": 1, "toPokemonId": 2, "trigger": "level-up"} + ) + assert evo.from_pokemon_id == 1