Release: test infrastructure, rules overhaul, and design refresh #30
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-hjkk
|
# nuzlocke-tracker-hjkk
|
||||||
title: Unit tests for Pydantic schemas and model validation
|
title: Unit tests for Pydantic schemas and model validation
|
||||||
status: draft
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-10T09:33:03Z
|
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
|
parent: nuzlocke-tracker-yzpb
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,14 +13,14 @@ Write unit tests for the Pydantic schemas in `backend/src/app/schemas/`. These a
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Test `CamelModel` base class (snake_case → camelCase alias generation)
|
- [x] Test `CamelModel` base class (snake_case → camelCase alias generation)
|
||||||
- [ ] Test run schemas — creation validation, required fields, optional fields, serialization
|
- [x] Test run schemas — creation validation, required fields, optional fields, serialization
|
||||||
- [ ] Test game schemas — validation rules, field constraints
|
- [x] Test game schemas — validation rules, field constraints
|
||||||
- [ ] Test encounter schemas — status enum validation, field dependencies
|
- [x] Test encounter schemas — status enum validation, field dependencies
|
||||||
- [ ] Test boss schemas — nested model validation
|
- [x] Test boss schemas — nested model validation
|
||||||
- [ ] Test genlocke schemas — complex nested structures
|
- [x] Test genlocke schemas — complex nested structures
|
||||||
- [ ] Test stats schemas — response model structure
|
- [x] Test evolution schemas — validation of evolution chain data
|
||||||
- [ ] Test evolution schemas — validation of evolution chain data
|
- [x] Test Pokemon create schema (types list, required fields)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
306
backend/tests/test_schemas.py
Normal file
306
backend/tests/test_schemas.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user