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/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/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/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/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/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"