From 6e368aeeaba211b07ff86ffd881b516c9e14da56 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 16 Oct 2026 23:25:44 +0300 Subject: [PATCH 1/2] fix: align migrations with models for bigint ids and required deck_id --- app/models.py | 2 +- ...6-10-16_bigint_ids_and_required_deck_id.py | 33 ++++++++++++++++ tests/test_migrations.py | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/2026-10-16_bigint_ids_and_required_deck_id.py create mode 100644 tests/test_migrations.py diff --git a/app/models.py b/app/models.py index 3551f82..0e91826 100644 --- a/app/models.py +++ b/app/models.py @@ -27,4 +27,4 @@ class Card(BigIntAuditBase): front: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False) back: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True) hint: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True) - deck_id: orm.Mapped[int] = orm.mapped_column(sa.Integer, sa.ForeignKey("decks.id")) + deck_id: orm.Mapped[int] = orm.mapped_column(sa.BigInteger, sa.ForeignKey("decks.id")) diff --git a/migrations/versions/2026-10-16_bigint_ids_and_required_deck_id.py b/migrations/versions/2026-10-16_bigint_ids_and_required_deck_id.py new file mode 100644 index 0000000..57b90cd --- /dev/null +++ b/migrations/versions/2026-10-16_bigint_ids_and_required_deck_id.py @@ -0,0 +1,33 @@ +"""bigint ids and required deck_id. + +Revision ID: 8e4a2c7b1f6d +Revises: 5d1c0f3a9b2e +Create Date: 2026-10-16 22:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "8e4a2c7b1f6d" +down_revision = "5d1c0f3a9b2e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("decks", "id", type_=sa.BigInteger(), existing_type=sa.Integer(), existing_nullable=False) + op.execute("ALTER SEQUENCE decks_id_seq AS bigint") + op.alter_column("cards", "id", type_=sa.BigInteger(), existing_type=sa.Integer(), existing_nullable=False) + op.execute("ALTER SEQUENCE cards_id_seq AS bigint") + op.alter_column("cards", "deck_id", type_=sa.BigInteger(), existing_type=sa.Integer(), nullable=False) + + +def downgrade() -> None: + op.alter_column("cards", "deck_id", type_=sa.Integer(), existing_type=sa.BigInteger(), nullable=True) + op.execute("ALTER SEQUENCE cards_id_seq AS integer") + op.alter_column("cards", "id", type_=sa.Integer(), existing_type=sa.BigInteger(), existing_nullable=False) + op.execute("ALTER SEQUENCE decks_id_seq AS integer") + op.alter_column("decks", "id", type_=sa.Integer(), existing_type=sa.BigInteger(), existing_nullable=False) diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..ac2cf81 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,38 @@ +import typing + +import sqlalchemy as sa +from alembic.autogenerate import compare_metadata +from alembic.runtime.migration import MigrationContext + +from app.models import METADATA +from app.resources.db import create_primary_sa_engine + + +def _compare_with_models(connection: sa.Connection) -> list[typing.Any]: + return compare_metadata(MigrationContext.configure(connection), METADATA) + + +async def test_migrations_match_models() -> None: + engine: typing.Final = create_primary_sa_engine() + try: + async with engine.connect() as connection: + diff = await connection.run_sync(_compare_with_models) + finally: + await engine.dispose() + + assert diff == [] + + +async def test_id_sequences_are_bigint() -> None: + engine: typing.Final = create_primary_sa_engine() + try: + async with engine.connect() as connection: + sequence_types = dict( + (await connection.execute(sa.text("SELECT sequence_name, data_type FROM information_schema.sequences"))) + .tuples() + .all() + ) + finally: + await engine.dispose() + + assert sequence_types == {"decks_id_seq": "bigint", "cards_id_seq": "bigint"} From e3bbdd7571de0cf9bde1ac4d5955307a55a4fa4a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 16 Oct 2026 23:35:47 +0300 Subject: [PATCH 2/2] test: check migrations with pytest-alembic instead of a hand-written drift test --- .github/workflows/main.yml | 15 ++++++++++-- AGENTS.md | 3 ++- justfile | 3 +++ pyproject.toml | 4 ++- tests/migrations/__init__.py | 0 tests/migrations/conftest.py | 14 +++++++++++ tests/migrations/test_migrations.py | 12 +++++++++ tests/test_migrations.py | 38 ----------------------------- uv.lock | 18 +++++++++++++- 9 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 tests/migrations/__init__.py create mode 100644 tests/migrations/conftest.py create mode 100644 tests/migrations/test_migrations.py delete mode 100644 tests/test_migrations.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a3c6ef..1026403 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,18 @@ jobs: pytest: runs-on: ubuntu-latest + name: pytest (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: app + run: | + uv run alembic upgrade head + uv run pytest . --cov=. --cov-report xml + # pytest-alembic upgrades an empty database itself, so no `alembic upgrade` first. + - name: migrations + run: uv run pytest tests/migrations --override-ini=addopts= services: postgres: image: postgres:latest @@ -52,8 +64,7 @@ jobs: - run: uv python pin 3.14 - run: | uv sync --all-extras --all-groups --frozen --no-install-project - uv run alembic upgrade head - uv run pytest . --cov=. --cov-report xml + ${{ matrix.run }} env: SERVICE_ENVIRONMENT: ci PYTHONDONTWRITEBYTECODE: 1 diff --git a/AGENTS.md b/AGENTS.md index c769ed6..43d5efa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ The development workflow runs inside Docker via `just` (`just --list` / read the Things the recipe names don't make obvious: - `just test [args]` recreates a clean DB first (`alembic downgrade base` → `upgrade head`) and tears the stack down afterward; `[args]` are passed through to `pytest`. +- `just test-migrations [args]` runs the `pytest-alembic` suite in `tests/migrations/` against an empty database: single head, upgrade, up/down consistency for every revision, and models matching the migrated schema (`alembic check` as a test). `just test` skips that directory (`--ignore` in `addopts`, `omit` in coverage) because it migrates the schema under the transaction-rollback fixture, so the recipe clears `addopts`. - `just run` applies migrations then starts the app on :8000. - `just lint` auto-fixes (eof-fixer + `ruff format` + `ruff check --fix` + `ty check`); CI (below) is the no-fix gate. @@ -22,7 +23,7 @@ Running a single test (inside container or with DB available): uv run pytest tests/test_decks.py::test_get_decks -x ``` -CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-fix`, `ty check`, then `alembic upgrade head` and `pytest` against a Postgres service. Match this locally before pushing. +CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-fix`, `ty check`, then two Postgres jobs: `alembic upgrade head` followed by `pytest`, and the `pytest-alembic` suite. Match this locally before pushing. ## Architecture diff --git a/justfile b/justfile index 9086db1..f950075 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,9 @@ sh: test *args: down && down docker compose run application sh -c "sleep 1 && uv run alembic downgrade base && uv run alembic upgrade head && uv run pytest {{ args }}" +test-migrations *args: down && down + docker compose run application sh -c "sleep 1 && uv run pytest tests/migrations --override-ini=addopts= {{ args }}" + run: docker compose run --service-ports application sh -c "sleep 1 && uv run alembic upgrade head && uv run python -m app" diff --git a/pyproject.toml b/pyproject.toml index 3b9dc8c..6dd01bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ "pytest-cov", "pytest-asyncio", "asgi_lifespan", + "pytest-alembic", ] lint = [ "ruff", @@ -79,7 +80,7 @@ isort.no-lines-before = ["standard-library", "local-folder"] ] [tool.pytest.ini_options] -addopts = "--cov=. --cov-report term-missing --cov-fail-under=100" +addopts = "--cov=. --cov-report term-missing --cov-fail-under=100 --ignore=tests/migrations" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" @@ -90,3 +91,4 @@ exclude_also = [ [tool.coverage.run] concurrency = ["thread", "greenlet"] +omit = ["tests/migrations/*"] diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/migrations/conftest.py b/tests/migrations/conftest.py new file mode 100644 index 0000000..73e0992 --- /dev/null +++ b/tests/migrations/conftest.py @@ -0,0 +1,14 @@ +import typing + +import pytest +from sqlalchemy import Engine, create_engine + +from app.settings import settings + + +@pytest.fixture +def alembic_engine() -> typing.Iterator[Engine]: + # Replaces pytest-alembic's default in-memory SQLite engine. + engine: typing.Final = create_engine(settings.db_dsn_parsed.set(drivername="postgresql+psycopg")) + yield engine + engine.dispose() diff --git a/tests/migrations/test_migrations.py b/tests/migrations/test_migrations.py new file mode 100644 index 0000000..1b937f7 --- /dev/null +++ b/tests/migrations/test_migrations.py @@ -0,0 +1,12 @@ +from pytest_alembic.tests import ( + test_model_definitions_match_ddl, + test_single_head_revision, + test_up_down_consistency, + test_upgrade, +) + + +_ = test_single_head_revision +_ = test_upgrade +_ = test_model_definitions_match_ddl +_ = test_up_down_consistency diff --git a/tests/test_migrations.py b/tests/test_migrations.py deleted file mode 100644 index ac2cf81..0000000 --- a/tests/test_migrations.py +++ /dev/null @@ -1,38 +0,0 @@ -import typing - -import sqlalchemy as sa -from alembic.autogenerate import compare_metadata -from alembic.runtime.migration import MigrationContext - -from app.models import METADATA -from app.resources.db import create_primary_sa_engine - - -def _compare_with_models(connection: sa.Connection) -> list[typing.Any]: - return compare_metadata(MigrationContext.configure(connection), METADATA) - - -async def test_migrations_match_models() -> None: - engine: typing.Final = create_primary_sa_engine() - try: - async with engine.connect() as connection: - diff = await connection.run_sync(_compare_with_models) - finally: - await engine.dispose() - - assert diff == [] - - -async def test_id_sequences_are_bigint() -> None: - engine: typing.Final = create_primary_sa_engine() - try: - async with engine.connect() as connection: - sequence_types = dict( - (await connection.execute(sa.text("SELECT sequence_name, data_type FROM information_schema.sequences"))) - .tuples() - .all() - ) - finally: - await engine.dispose() - - assert sequence_types == {"decks_id_seq": "bigint", "cards_id_seq": "bigint"} diff --git a/uv.lock b/uv.lock index 9788809..755638d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" [[package]] @@ -281,6 +281,7 @@ dev = [ { name = "httpx" }, { name = "polyfactory" }, { name = "pytest" }, + { name = "pytest-alembic" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -312,6 +313,7 @@ dev = [ { name = "httpx" }, { name = "polyfactory" }, { name = "pytest" }, + { name = "pytest-alembic" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -977,6 +979,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-alembic" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "pytest" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/99/272ace37071006a0050abf7b6940c6cfdcf58e18f27385be668f6a934ee0/pytest_alembic-0.13.1.tar.gz", hash = "sha256:96a54ec76d4704fffb433880c5a84f5c3ea5980bb3f6509d868fef40e6e9a79f", size = 39777, upload-time = "2026-10-02T16:21:52.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/57/19e2e43140c04be1219f408fe840226c0f4d01550c9d5357f7ee6c86aa73/pytest_alembic-0.13.1-py3-none-any.whl", hash = "sha256:141b410c2d338d98401981dc4f8a848ea46c2c5c46dae106f1bcbed33dccdc47", size = 46278, upload-time = "2026-10-02T16:21:51.261Z" }, +] + [[package]] name = "pytest-asyncio" version = "1.4.0"