diff --git a/Makefile b/Makefile index 5fdf2cd..00db659 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ PROJECT_NAME ?= $(notdir $(abspath .)) INFRA_SERVICES ?= db_pg INFRA_INIT_SERVICES ?= MIGRATION_DB_SERVICE ?= db_pg -STAIRWAY_TEST ?= tests/integration/with_infra/test_stairway.py +STAIRWAY_TEST ?= tests/integration/migrations/test_stairway.py # ----------------------------- # Internal vars / aliases @@ -44,10 +44,12 @@ PYTEST_PATHS_LIGHT := \ tests/sanity \ tests/unit \ tests/integration/no_infra -PYTEST_PATHS_ALL := \ +PYTEST_PATHS_APP_INFRA := \ $(PYTEST_PATHS_LIGHT) \ tests/smoke \ tests/integration/with_infra +PYTEST_PATHS_MIGRATIONS := \ + tests/integration/migrations # Pytest args PYTEST_ARGS_VERBOSE := -s -vv @@ -130,14 +132,14 @@ stop-all: # Migrations .PHONY: migration migration: local-env - PROJECT_NAME=$(PROJECT_NAME) \ MIGRATION_DB_SERVICE=$(MIGRATION_DB_SERVICE) \ + INFRA_INIT_SERVICES="$(INFRA_INIT_SERVICES)" \ STAIRWAY_TEST=$(STAIRWAY_TEST) \ $(MIGRATION) "$(msg)" # Tests (with infra) -.PHONY: test-docker -test-docker: docker-env +.PHONY: test-docker test-docker-app test-docker-migrations +test-docker-app: docker-env rc=0; \ $(DC_TEST_DOCKER) down -v --remove-orphans >/dev/null 2>&1 || true; \ if [ -n "$(strip $(INFRA_SERVICES))" ]; then \ @@ -150,16 +152,36 @@ test-docker: docker-env fi; \ $(DC_TEST_DOCKER) run --build --name $(TEST_RUNNER) app \ pytest $(PYTEST_ARGS_VERBOSE) \ - $(PYTEST_PATHS_ALL) \ + $(PYTEST_PATHS_APP_INFRA) \ $(PYTEST_ARGS_COV_DOCKER) \ || rc=$$?; \ docker cp $(TEST_RUNNER):/tmp/.coverage ./.coverage.docker 2>/dev/null || true; \ docker rm $(TEST_RUNNER) >/dev/null 2>&1 || true; \ $(DC_TEST_DOCKER) down -v --remove-orphans; \ - coverage html --data-file=.coverage.docker -d htmlcov-docker && \ - echo "Coverage HTML report: htmlcov-docker/index.html" || true; \ exit $$rc +test-docker-migrations: docker-env + if [ -z "$(strip $(PYTEST_PATHS_MIGRATIONS))" ] || [ -z "$(strip $(MIGRATION_DB_SERVICE))" ]; then \ + echo "PYTEST_PATHS_MIGRATIONS or MIGRATION_DB_SERVICE is empty, skipping migrations tests"; \ + exit 0; \ + fi; \ + rc=0; \ + $(DC_TEST_DOCKER) down -v --remove-orphans >/dev/null 2>&1 || true; \ + $(DC_TEST_DOCKER) up -d --build --wait --wait-timeout 180 $(MIGRATION_DB_SERVICE); \ + $(DC_TEST_DOCKER) run --build --no-deps --name $(TEST_RUNNER) app \ + pytest $(PYTEST_ARGS_VERBOSE) \ + $(PYTEST_PATHS_MIGRATIONS) \ + || rc=$$?; \ + docker rm $(TEST_RUNNER) >/dev/null 2>&1 || true; \ + $(DC_TEST_DOCKER) down -v --remove-orphans; \ + exit $$rc + +test-docker: + $(MAKE) test-docker-app + $(MAKE) test-docker-migrations + coverage html --data-file=.coverage.docker -d htmlcov-docker && \ + echo "Coverage HTML report: htmlcov-docker/index.html" || true + .PHONY: prune prune: $(DOCKER_PRUNE) diff --git a/scripts/makefile/migration.sh b/scripts/makefile/migration.sh index 70c88b6..e0f70a8 100755 --- a/scripts/makefile/migration.sh +++ b/scripts/makefile/migration.sh @@ -8,12 +8,12 @@ if [ "$#" -ne 1 ] || [ -z "$1" ]; then fi slug="$1" -: "${PROJECT_NAME:?must be set in Makefile}" +MIGRATION_PROJECT="$(basename "$PWD")-migration" : "${MIGRATION_DB_SERVICE:?must be set in Makefile (transactional db service for alembic)}" -trap 'docker compose -p "$PROJECT_NAME" stop "$MIGRATION_DB_SERVICE" >/dev/null' EXIT +trap 'docker compose -p "$MIGRATION_PROJECT" down -v --remove-orphans >/dev/null' EXIT -docker compose -p "$PROJECT_NAME" up -d --build --wait --wait-timeout 180 "$MIGRATION_DB_SERVICE" +docker compose -p "$MIGRATION_PROJECT" up -d --build --wait --wait-timeout 180 "$MIGRATION_DB_SERVICE" alembic upgrade head alembic revision --autogenerate -m "$slug" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..92d53f9 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,18 @@ +import os +from typing import Final + +import pytest + +ALLOW_DESTRUCTIVE_TEST_CLEANUP: Final[str] = "ALLOW_DESTRUCTIVE_TEST_CLEANUP" +ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE: Final[str] = "1" + + +@pytest.fixture(scope="session") +def allow_destructive() -> None: + """Use on fixtures that require potentially dangerous cleanup.""" + if os.getenv(ALLOW_DESTRUCTIVE_TEST_CLEANUP) != ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE: + raise pytest.UsageError( + "Destructive cleanup is disabled: " + f"{ALLOW_DESTRUCTIVE_TEST_CLEANUP} must be set to {ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE}. " + "This guard prevents accidental cleanup of non-test data." + ) diff --git a/tests/integration/migrations/__init__.py b/tests/integration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/with_infra/test_stairway.py b/tests/integration/migrations/test_stairway.py similarity index 100% rename from tests/integration/with_infra/test_stairway.py rename to tests/integration/migrations/test_stairway.py diff --git a/tests/integration/with_infra/conftest.py b/tests/integration/with_infra/conftest.py index 30adff5..c961f56 100644 --- a/tests/integration/with_infra/conftest.py +++ b/tests/integration/with_infra/conftest.py @@ -1,4 +1,3 @@ -import os from collections.abc import AsyncIterator, Sequence from typing import Final, cast @@ -16,19 +15,6 @@ from app.outbound.persistence_sqla.registry import mapper_registry LIFESPAN_MANAGER_STARTUP_TIMEOUT_S: Final[int] = 30 -ALLOW_DESTRUCTIVE_TEST_CLEANUP: Final[str] = "ALLOW_DESTRUCTIVE_TEST_CLEANUP" -ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE: Final[str] = "1" - - -@pytest.fixture(scope="session") -def allow_destructive() -> None: - """Use on fixtures that require potentially dangerous cleanup.""" - if os.getenv(ALLOW_DESTRUCTIVE_TEST_CLEANUP) != ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE: - raise pytest.UsageError( - "Destructive cleanup is disabled: " - f"{ALLOW_DESTRUCTIVE_TEST_CLEANUP} must be set to {ALLOW_DESTRUCTIVE_TEST_CLEANUP_EXPECTED_VALUE}. " - "This guard prevents accidental cleanup of non-test data." - ) @pytest.fixture @@ -79,7 +65,9 @@ async def it_db_clean( it_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: table_names = [table.name for table in mapper_registry.metadata.sorted_tables if table.name != "alembic_version"] - assert table_names, "it_db_clean: no tables found in mapper_registry.metadata (fixture is a no-op)" + if not table_names: + return + sql = "TRUNCATE " + ", ".join(f'"{name}"' for name in table_names) + " RESTART IDENTITY CASCADE;" async with it_sessionmaker() as session: