diff --git a/AGENTS.md b/AGENTS.md index 8cc6156..d8c05b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,13 +84,13 @@ class ListDecksUseCase: ### Database layer - `app/models.py` — `BigIntAuditBase` from `advanced_alchemy` (auto `id`, `created_at`, `updated_at`). The module aliases `orm_registry.metadata` onto `orm.DeclarativeBase.metadata` so Alembic autogenerate sees both. New models go here. -- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING` and `get_or_create` sends `INSERT ... ON CONFLICT ... RETURNING`. `update_cards` sends one `UPDATE cards ... FROM (VALUES ...) WHERE cards.id = updates.id AND cards.deck_id = :deck_id RETURNING *` and raises `NotFoundError` (404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. An `INSERT ... ON CONFLICT (id)` upsert would move a card in from another deck and would insert client-chosen ids without advancing `cards_id_seq`, so a later POST would collide on the primary key. `schemas.CardUpdates` rejects duplicate ids with a 422 before any query runs. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`. +- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING` and `get_or_create` sends `INSERT ... ON CONFLICT ... RETURNING`. `update_cards` sends one `UPDATE cards ... FROM unnest(:ids, :fronts, :backs, :hints) AS updates(...) WHERE cards.id = updates.id AND cards.deck_id = :deck_id RETURNING *`. It binds one array per column, so the SQL text stays the same for any number of cards: SQLAlchemy caches it and asyncpg prepares it once per connection, which a multi-row `VALUES` would not allow. It raises `NotFoundError` (404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. An `INSERT ... ON CONFLICT (id)` upsert would move a card in from another deck and would insert client-chosen ids without advancing `cards_id_seq`, so a later POST would collide on the primary key. `schemas.CardCreates` and `schemas.CardUpdates` cap a request at `MAX_CARDS_PER_REQUEST` cards, and `schemas.CardUpdates` rejects duplicate ids, both with a 422 before any query runs. Ids in paths, `after` and card bodies are bounded to Postgres `bigint` (`schemas.MAX_ID`), so an oversized id is a 422 and never reaches asyncpg. A POST to a deck that does not exist fails on the foreign key, and `ForeignKeyError` maps to 404. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`. - `app/resources/db.py` — engine factories live here and are DI creators: modern-di resolves their parameter annotations at runtime, so `pyproject.toml` exempts `app/resources/` from ruff's `TC` rules. `create_session` passes `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it. - `migrations/env.py` swaps the asyncpg driver for the sync `postgresql` driver and uses `app.models.METADATA` as `target_metadata`. ### Settings -`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. +`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger. ### Tests diff --git a/app/__main__.py b/app/__main__.py index 948670d..c25b31d 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -1,6 +1,5 @@ import granian from granian.constants import Interfaces, Loops -from granian.log import LogLevels from app.settings import settings @@ -12,6 +11,6 @@ address=settings.app_host, port=settings.app_port, interface=Interfaces.ASGI, - log_level=LogLevels(settings.log_level), + log_level=settings.log_level, loop=Loops.uvloop, ).serve() diff --git a/app/api/__init__.py b/app/api/__init__.py index e69de29..109e124 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -0,0 +1,8 @@ +import typing + +import fastapi + +from app import schemas + + +PathId = typing.Annotated[int, fastapi.Path(gt=0, le=schemas.MAX_ID)] diff --git a/app/api/cards.py b/app/api/cards.py index 5e98e03..59c22b0 100644 --- a/app/api/cards.py +++ b/app/api/cards.py @@ -4,6 +4,7 @@ from modern_di_fastapi import FromDI from app import schemas +from app.api import PathId from app.use_cases.create_cards import CreateCardsUseCase from app.use_cases.get_card import GetCardUseCase from app.use_cases.list_cards import ListCardsUseCase @@ -15,8 +16,8 @@ @ROUTER.get("/decks/{deck_id}/cards/") async def list_cards( - deck_id: int, - after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None, + deck_id: PathId, + after: typing.Annotated[int | None, fastapi.Query(gt=0, le=schemas.MAX_ID)] = None, limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT, list_cards_use_case: ListCardsUseCase = FromDI(ListCardsUseCase), ) -> schemas.CardsPage: @@ -26,7 +27,7 @@ async def list_cards( @ROUTER.get("/cards/{card_id}/") async def get_card( - card_id: int, + card_id: PathId, get_card_use_case: GetCardUseCase = FromDI(GetCardUseCase), ) -> schemas.Card: return schemas.Card.model_validate(await get_card_use_case(card_id=card_id)) @@ -34,8 +35,8 @@ async def get_card( @ROUTER.post("/decks/{deck_id}/cards/") async def create_cards( - deck_id: int, - data: list[schemas.CardCreate], + deck_id: PathId, + data: schemas.CardCreates, create_cards_use_case: CreateCardsUseCase = FromDI(CreateCardsUseCase), ) -> schemas.Cards: return schemas.Cards.from_models(await create_cards_use_case(deck_id=deck_id, data=data)) @@ -43,7 +44,7 @@ async def create_cards( @ROUTER.put("/decks/{deck_id}/cards/") async def update_cards( - deck_id: int, + deck_id: PathId, data: schemas.CardUpdates, update_cards_use_case: UpdateCardsUseCase = FromDI(UpdateCardsUseCase), ) -> schemas.Cards: diff --git a/app/api/decks.py b/app/api/decks.py index 3296b1e..8d5ff76 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -4,6 +4,7 @@ from modern_di_fastapi import FromDI from app import schemas +from app.api import PathId from app.use_cases.create_deck import CreateDeckUseCase from app.use_cases.get_deck import GetDeckUseCase from app.use_cases.list_decks import ListDecksUseCase @@ -15,7 +16,7 @@ @ROUTER.get("/decks/") async def list_decks( - after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None, + after: typing.Annotated[int | None, fastapi.Query(gt=0, le=schemas.MAX_ID)] = None, limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT, list_decks_use_case: ListDecksUseCase = FromDI(ListDecksUseCase), ) -> schemas.DecksPage: @@ -25,7 +26,7 @@ async def list_decks( @ROUTER.get("/decks/{deck_id}/") async def get_deck( - deck_id: int, + deck_id: PathId, get_deck_use_case: GetDeckUseCase = FromDI(GetDeckUseCase), ) -> schemas.DeckWithCards: return schemas.DeckWithCards.model_validate(await get_deck_use_case(deck_id=deck_id)) @@ -33,7 +34,7 @@ async def get_deck( @ROUTER.put("/decks/{deck_id}/") async def update_deck( - deck_id: int, + deck_id: PathId, data: schemas.DeckUpdate, update_deck_use_case: UpdateDeckUseCase = FromDI(UpdateDeckUseCase), ) -> schemas.Deck: diff --git a/app/application.py b/app/application.py index 4b45ea3..91ba52d 100644 --- a/app/application.py +++ b/app/application.py @@ -3,7 +3,7 @@ import modern_di import modern_di_fastapi -from advanced_alchemy.exceptions import DuplicateKeyError, NotFoundError +from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError from lite_bootstrap import FastAPIBootstrapper from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor @@ -39,4 +39,8 @@ def build_app() -> fastapi.FastAPI: NotFoundError, exceptions.not_found_error_handler, # ty: ignore[invalid-argument-type] ) + app.add_exception_handler( + ForeignKeyError, + exceptions.not_found_error_handler, # ty: ignore[invalid-argument-type] + ) return app diff --git a/app/exceptions.py b/app/exceptions.py index 11a7bed..ba364f0 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: - from advanced_alchemy.exceptions import DuplicateKeyError, NotFoundError + from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError from starlette.requests import Request @@ -16,7 +16,7 @@ async def duplicate_key_error_handler(_: Request, exc: DuplicateKeyError) -> JSO ) -async def not_found_error_handler(_: Request, __: NotFoundError) -> JSONResponse: +async def not_found_error_handler(_: Request, __: NotFoundError | ForeignKeyError) -> JSONResponse: return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, content={"detail": "Not found"}, diff --git a/app/models.py b/app/models.py index 0e91826..a3d5a05 100644 --- a/app/models.py +++ b/app/models.py @@ -17,12 +17,15 @@ class Deck(BigIntAuditBase): idempotency_key: orm.Mapped[uuid.UUID] = orm.mapped_column(sa.Uuid, nullable=False) name: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False) description: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True) - cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True) + cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True, order_by="Card.id") class Card(BigIntAuditBase): __tablename__ = "cards" - __table_args__ = (sa.UniqueConstraint("deck_id", "front", name="card_deck_id_front_uc"),) + __table_args__ = ( + sa.UniqueConstraint("deck_id", "front", name="card_deck_id_front_uc"), + sa.Index("ix_cards_deck_id_id", "deck_id", "id"), + ) front: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False) back: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True) diff --git a/app/repositories.py b/app/repositories.py index 83d1fe5..e4b5746 100644 --- a/app/repositories.py +++ b/app/repositories.py @@ -75,17 +75,25 @@ async def add_cards(self, deck_id: int, cards: list[schemas.CardCreate]) -> Sequ async def update_cards(self, deck_id: int, cards: list[schemas.CardUpdate]) -> Sequence[models.Card]: if not cards: return [] - updates: typing.Final = sa.values( - sa.column("id", sa.BigInteger), - sa.column("front", sa.String), - sa.column("back", sa.String), - sa.column("hint", sa.String), - name="updates", - ).data([(card.id, card.front, card.back, card.hint) for card in cards]) + updates: typing.Final = ( + sa.func.unnest( + sa.bindparam("ids", [card.id for card in cards], type_=postgresql.ARRAY(sa.BigInteger)), + sa.bindparam("fronts", [card.front for card in cards], type_=postgresql.ARRAY(sa.String)), + sa.bindparam("backs", [card.back for card in cards], type_=postgresql.ARRAY(sa.String)), + sa.bindparam("hints", [card.hint for card in cards], type_=postgresql.ARRAY(sa.String)), + ) + .table_valued( + sa.column("id", sa.BigInteger), + sa.column("front", sa.String), + sa.column("back", sa.String), + sa.column("hint", sa.String), + ) + .render_derived(name="updates") + ) statement: typing.Final = ( sa.update(models.Card) .where(models.Card.id == updates.c.id, models.Card.deck_id == deck_id) - .values(front=updates.c.front, back=updates.c.back, hint=updates.c.hint, updated_at=sa.func.now()) + .values(front=updates.c.front, back=updates.c.back, hint=updates.c.hint) .returning(models.Card) ) with wrap_sqlalchemy_exception(error_messages=DEFAULT_ERROR_MESSAGE_TEMPLATES, dialect_name="postgresql"): diff --git a/app/schemas.py b/app/schemas.py index e8df93f..11fdbbb 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Annotated, Final, Protocol, Self import pydantic -from pydantic import BaseModel, PositiveInt +from pydantic import BaseModel, Field, PositiveInt if TYPE_CHECKING: @@ -11,6 +11,10 @@ DEFAULT_PAGE_LIMIT: Final = 50 MAX_PAGE_LIMIT: Final = 100 +MAX_CARDS_PER_REQUEST: Final = 1000 +MAX_ID: Final = 2**63 - 1 + +Id = Annotated[int, Field(gt=0, le=MAX_ID)] class HasId(Protocol): @@ -52,7 +56,7 @@ class CardCreate(CardBase): class CardUpdate(CardBase): - id: PositiveInt + id: Id def _ensure_unique_ids(cards: list[CardUpdate]) -> list[CardUpdate]: @@ -62,7 +66,10 @@ def _ensure_unique_ids(cards: list[CardUpdate]) -> list[CardUpdate]: return cards -CardUpdates = Annotated[list[CardUpdate], pydantic.AfterValidator(_ensure_unique_ids)] +CardCreates = Annotated[list[CardCreate], Field(max_length=MAX_CARDS_PER_REQUEST)] +CardUpdates = Annotated[ + list[CardUpdate], Field(max_length=MAX_CARDS_PER_REQUEST), pydantic.AfterValidator(_ensure_unique_ids) +] class Card(CardUpdate): @@ -97,7 +104,7 @@ class Deck(DeckBase): class DeckWithCards(Deck): - """Deck detail view; cards are eager-loaded (selectinload) and always present.""" + """Deck detail view; cards are eager-loaded (joinedload) and always present.""" cards: list[Card] diff --git a/app/settings.py b/app/settings.py index 978764a..a6d0d93 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,4 +1,8 @@ +import typing + +import pydantic import pydantic_settings +from granian.log import LogLevels, log_levels_map from lite_bootstrap import FastAPIConfig from sqlalchemy.engine.url import URL, make_url @@ -8,7 +12,7 @@ class Settings(pydantic_settings.BaseSettings): service_version: str = "1.0.0" service_environment: str = "local" service_debug: bool = False - log_level: str = "info" + log_level: typing.Annotated[LogLevels, pydantic.BeforeValidator(str.lower)] = LogLevels.info db_dsn: str = "postgresql+asyncpg://postgres:password@db/postgres" db_replica_dsn: str = "" @@ -47,6 +51,7 @@ def api_bootstrapper_config(self) -> FastAPIConfig: cors_allowed_methods=self.cors_allowed_methods, cors_allowed_headers=self.cors_allowed_headers, cors_exposed_headers=self.cors_exposed_headers, + logging_log_level=log_levels_map[self.log_level], logging_buffer_capacity=self.logging_buffer_capacity, swagger_offline_docs=self.swagger_offline_docs, ) diff --git a/migrations/versions/2026-10-17_card_deck_id_id_index.py b/migrations/versions/2026-10-17_card_deck_id_id_index.py new file mode 100644 index 0000000..e62f0bf --- /dev/null +++ b/migrations/versions/2026-10-17_card_deck_id_id_index.py @@ -0,0 +1,24 @@ +"""card deck_id id index. + +Revision ID: 4004ffe4656f +Revises: 8e4a2c7b1f6d +Create Date: 2026-10-17 12:13:24.538852 + +""" + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "4004ffe4656f" +down_revision = "8e4a2c7b1f6d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index("ix_cards_deck_id_id", "cards", ["deck_id", "id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_cards_deck_id_id", table_name="cards") diff --git a/pyproject.toml b/pyproject.toml index c37dd0c..80d70a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ flake8-type-checking.runtime-evaluated-base-classes = ["pydantic.BaseModel", "ad isort.no-lines-before = ["standard-library", "local-folder"] [tool.ruff.lint.extend-per-file-ignores] +"app/api/*.py" = [ + "TC", # FastAPI reads route annotations at runtime +] "app/resources/*.py" = [ "TC", # modern-di reads DI creator annotations at runtime ] diff --git a/tests/test_cards.py b/tests/test_cards.py index 1d31f07..8e70c96 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from fastapi import status -from app import models +from app import models, schemas from tests import factories @@ -24,9 +24,6 @@ async def test_get_cards_empty(client: AsyncClient) -> None: assert response.status_code == status.HTTP_200_OK assert len(response.json()["items"]) == 0 - response = await client.get("/api/cards/0/") - assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_get_cards(client: AsyncClient) -> None: deck = await factories.DeckModelFactory.create_async() @@ -268,3 +265,59 @@ async def test_list_cards_rejects_bad_page_params(client: AsyncClient, params: d response = await client.get(f"/api/decks/{deck.id}/cards/", params=params) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +async def test_create_cards_in_a_missing_deck(client: AsyncClient) -> None: + response = await client.post("/api/decks/999/cards/", json=[{"front": "orphan"}]) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.parametrize("method", ["post", "put"]) +async def test_card_writes_reject_too_many_cards(client: AsyncClient, statements: list[str], method: str) -> None: + deck = await factories.DeckModelFactory.create_async() + statements.clear() + + cards = [{"id": i, "front": str(i)} for i in range(1, schemas.MAX_CARDS_PER_REQUEST + 2)] + response = await client.request(method, f"/api/decks/{deck.id}/cards/", json=cards) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert statements == [] + + +async def test_update_cards_sends_the_same_sql_for_any_number_of_cards( + client: AsyncClient, statements: list[str] +) -> None: + deck = await factories.DeckModelFactory.create_async() + card1, card2 = await factories.CardModelFactory.create_batch_async(size=2, deck_id=deck.id) + statements.clear() + + response = await client.put(f"/api/decks/{deck.id}/cards/", json=[{"id": card1.id, "front": "one"}]) + assert response.status_code == status.HTTP_200_OK + response = await client.put( + f"/api/decks/{deck.id}/cards/", + json=[{"id": card1.id, "front": "first"}, {"id": card2.id, "front": "second"}], + ) + assert response.status_code == status.HTTP_200_OK + assert [x["front"] for x in response.json()["items"]] == ["first", "second"] + assert len(statements) == 2 # noqa: PLR2004 + assert statements[0] == statements[1] + + +@pytest.mark.parametrize( + ("method", "path", "body", "params"), + [ + ("get", f"/api/cards/{2**63}/", None, None), + ("get", f"/api/decks/{2**63}/cards/", None, None), + ("get", "/api/decks/1/cards/", None, {"after": 2**63}), + ("post", f"/api/decks/{2**63}/cards/", [{"front": "x"}], None), + ("put", "/api/decks/1/cards/", [{"id": 2**63, "front": "x"}], None), + ], +) +async def test_card_endpoints_reject_ids_out_of_bigint_range( + client: AsyncClient, + method: str, + path: str, + body: list[dict[str, int | str]] | None, + params: dict[str, int] | None, +) -> None: + response = await client.request(method, path, json=body, params=params) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/tests/test_decks.py b/tests/test_decks.py index 44da28c..83c5222 100644 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -25,7 +25,7 @@ async def test_get_decks_empty(client: AsyncClient) -> None: async def test_get_decks_not_exist(client: AsyncClient) -> None: - response = await client.get("/api/decks/0/") + response = await client.get("/api/decks/999/") assert response.status_code == status.HTTP_404_NOT_FOUND @@ -87,6 +87,32 @@ async def test_get_one_deck_loads_cards_in_one_query(client: AsyncClient, statem assert len(statements) == 1 +async def test_get_one_deck_orders_cards_by_id(client: AsyncClient) -> None: + deck = await factories.DeckModelFactory.create_async() + card1, card2 = await factories.CardModelFactory.create_batch_async(size=2, deck_id=deck.id) + response = await client.put(f"/api/decks/{deck.id}/cards/", json=[{"id": card1.id, "front": "moved"}]) + assert response.status_code == status.HTTP_200_OK + + response = await client.get(f"/api/decks/{deck.id}/") + assert response.status_code == status.HTTP_200_OK + assert [x["id"] for x in response.json()["cards"]] == [card1.id, card2.id] + + +@pytest.mark.parametrize( + ("method", "path", "params"), + [ + ("get", f"/api/decks/{2**63}/", None), + ("put", f"/api/decks/{2**63}/", None), + ("get", "/api/decks/", {"after": 2**63}), + ], +) +async def test_deck_endpoints_reject_ids_out_of_bigint_range( + client: AsyncClient, method: str, path: str, params: dict[str, int] | None +) -> None: + response = await client.request(method, path, json={"name": "x"}, params=params) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + @pytest.mark.parametrize( ("name", "description", "status_code"), [ diff --git a/tests/test_settings.py b/tests/test_settings.py index f1a9573..149dac8 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,3 +1,7 @@ +import logging + +from granian.log import LogLevels + from app.settings import Settings @@ -12,3 +16,10 @@ def test_api_bootstrapper_config_reads_instance_not_global() -> None: def test_pool_pre_ping_is_off_by_default() -> None: assert Settings().db_pool_pre_ping is False + + +def test_log_level_is_case_insensitive_and_reaches_app_logging() -> None: + custom = Settings(log_level="WARNING") + + assert custom.log_level == LogLevels.warning + assert custom.api_bootstrapper_config.logging_log_level == logging.WARNING