diff --git a/.woodpecker/pipeline.yml b/.woodpecker/pipeline.yml index 0dda2c6..c411449 100644 --- a/.woodpecker/pipeline.yml +++ b/.woodpecker/pipeline.yml @@ -2,6 +2,14 @@ when: event: [push, pull_request] branch: [dev, main, master] +services: + - name: postgres + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: blog_test + steps: - name: deps image: python:3.13 @@ -66,10 +74,27 @@ steps: UV_CACHE_DIR: /root/.cache/uv UV_LINK_MODE: copy UV_PYTHON: "3.13" + COVERAGE_FILE: .coverage.unit depends_on: [deps] commands: - pip install uv - - uv run --no-sync pytest tests/unit/ + - uv run --no-sync pytest tests/unit/ -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0" + + - name: test-integration + image: python:3.13 + volumes: + - /tmp/uv-cache:/root/.cache/uv + environment: + UV_CACHE_DIR: /root/.cache/uv + UV_LINK_MODE: copy + UV_PYTHON: "3.13" + DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test + SKIP_INIT_DB: "1" + COVERAGE_FILE: .coverage.integration + depends_on: [deps] + commands: + - pip install uv + - uv run --no-sync pytest tests/integration/ -v -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0" - name: test-e2e image: python:3.13 @@ -79,11 +104,29 @@ steps: UV_CACHE_DIR: /root/.cache/uv UV_LINK_MODE: copy UV_PYTHON: "3.13" - depends_on: [deps] + DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test + SKIP_INIT_DB: "1" + depends_on: [test-integration] commands: - pip install uv + - uv run --no-sync alembic upgrade head - apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 - uv run --no-sync playwright install chromium - uv run --no-sync blog & - sleep 5 - uv run --no-sync pytest tests/e2e/ -v --no-cov + + - name: coverage + image: python:3.13 + volumes: + - /tmp/uv-cache:/root/.cache/uv + environment: + UV_CACHE_DIR: /root/.cache/uv + UV_LINK_MODE: copy + UV_PYTHON: "3.13" + depends_on: [test-unit, test-integration, test-e2e] + commands: + - pip install uv + - uv run --no-sync coverage combine .coverage.unit .coverage.integration + - uv run --no-sync coverage report --fail-under=70 --include=app/* + - uv run --no-sync coverage html diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9043547 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite+aiosqlite:///./blog.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c5d42a8 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,64 @@ +import asyncio +import os +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import create_async_engine + +from alembic import context +from app.infrastructure.database.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _get_database_url() -> str: + url = os.environ.get("DB_URL") or config.get_main_option("sqlalchemy.url") + if not url: + raise RuntimeError("Database URL not configured") + return url + + +def run_migrations_offline() -> None: + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + db_url = _get_database_url() + connectable = create_async_engine(db_url, poolclass=pool.NullPool) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..cb8f08e --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,30 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/5357028a1574_initial_migration_for_postorm.py b/alembic/versions/5357028a1574_initial_migration_for_postorm.py new file mode 100644 index 0000000..cd52e24 --- /dev/null +++ b/alembic/versions/5357028a1574_initial_migration_for_postorm.py @@ -0,0 +1,48 @@ +"""Initial migration for PostORM. + +Revision ID: 5357028a1574 +Revises: +Create Date: 2026-05-09 20:56:26.292255 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5357028a1574" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "posts", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("slug", sa.String(200), nullable=False), + sa.Column("author_id", sa.String(100), nullable=False), + sa.Column("published", sa.Boolean(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index("ix_posts_author_id", "posts", ["author_id"]) + op.create_index("ix_posts_published", "posts", ["published"]) + op.create_index("ix_posts_slug", "posts", ["slug"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_posts_slug", table_name="posts") + op.drop_index("ix_posts_published", table_name="posts") + op.drop_index("ix_posts_author_id", table_name="posts") + op.drop_table("posts") diff --git a/app/infrastructure/database/connection.py b/app/infrastructure/database/connection.py index ea2ce92..22c5689 100644 --- a/app/infrastructure/database/connection.py +++ b/app/infrastructure/database/connection.py @@ -78,7 +78,13 @@ async def init_db() -> None: Creates all tables defined in the metadata. Should be called on application startup. + Skipped in CI/production where alembic manages schema. """ + import os + + if os.environ.get("SKIP_INIT_DB", "").lower() in ("1", "true", "yes"): + return + from app.infrastructure.database.models import Base async with engine.begin() as conn: diff --git a/pyproject.toml b/pyproject.toml index 4f51185..af82863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "sqlalchemy>=2.0.0", "aiosqlite>=0.21.0", "asyncpg>=0.30.0", + "alembic>=1.15.0", "dishka>=1.5.0", "httpx>=0.28.0", "jinja2>=3.1.6", @@ -42,6 +43,7 @@ dev = [ tests = [ "httpx>=0.28.1", "mimesis>=19.1.0", + "psycopg2-binary>=2.9.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", @@ -81,7 +83,7 @@ target-version = "py313" line-length = 100 [tool.ruff.lint] -select = ["E", "F", "I", "W", "B", "C4", "SIM"] +select = ["E", "F", "W", "B", "C4", "SIM"] ignore = ["E501"] [tool.isort] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..86844c2 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,55 @@ +import asyncio +import os +from collections.abc import AsyncGenerator +from typing import Generator + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from alembic import command +from alembic.config import Config +from app.infrastructure.config import settings +from app.infrastructure.database.models import Base + + +def _sync_url(db_url: str) -> str: + return db_url.replace("+aiosqlite", "").replace("+asyncpg", "") + + +def _build_alembic_config(db_url: str) -> Config: + alembic_cfg = Config("alembic.ini") + alembic_cfg.set_main_option("sqlalchemy.url", db_url) + return alembic_cfg + + +@pytest.fixture(scope="session") +def event_loop() -> Generator[asyncio.AbstractEventLoop]: + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session", autouse=True) +def setup_database() -> Generator[None]: + db_url = os.environ.get("DB_URL", settings.database_url) + sync_engine = create_engine(_sync_url(db_url)) + Base.metadata.drop_all(sync_engine) + Base.metadata.create_all(sync_engine) + sync_engine.dispose() + + alembic_cfg = _build_alembic_config(db_url) + command.stamp(alembic_cfg, "head") + yield + + +@pytest.fixture +async def db_session() -> AsyncGenerator[AsyncSession]: + db_url = os.environ.get("DB_URL", settings.database_url) + test_engine = create_async_engine(db_url) + session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + async with session_factory() as session: + yield session + + await test_engine.dispose() diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..b295274 --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,44 @@ +from typing import cast + +import pytest +from sqlalchemy import inspect +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + + +class TestMigrations: + @pytest.mark.asyncio + async def test_initial_migration_creates_post_table(self, db_session: AsyncSession) -> None: + engine = cast(AsyncEngine, db_session.bind) + async with engine.connect() as conn: + tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()) + assert "posts" in tables + + @pytest.mark.asyncio + async def test_posts_table_has_expected_columns(self, db_session: AsyncSession) -> None: + engine = cast(AsyncEngine, db_session.bind) + async with engine.connect() as conn: + columns = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_columns("posts")) + column_names = [col["name"] for col in columns] + expected = [ + "id", + "title", + "content", + "slug", + "author_id", + "published", + "tags", + "created_at", + "updated_at", + ] + for col in expected: + assert col in column_names + + @pytest.mark.asyncio + async def test_posts_table_has_indexes(self, db_session: AsyncSession) -> None: + engine = cast(AsyncEngine, db_session.bind) + async with engine.connect() as conn: + indexes = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_indexes("posts")) + index_names = [idx["name"] for idx in indexes] + assert "ix_posts_slug" in index_names + assert "ix_posts_author_id" in index_names + assert "ix_posts_published" in index_names diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000..e1909db --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,139 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.entities import Post +from app.domain.value_objects import Title +from app.infrastructure.repositories.post import SQLAlchemyPostRepository + + +class TestSQLAlchemyPostRepository: + @pytest.fixture + async def repo(self, db_session: AsyncSession) -> SQLAlchemyPostRepository: + return SQLAlchemyPostRepository(db_session) + + @pytest.mark.asyncio + async def test_add_creates_post( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="Test Title", + content_str="Test content with enough characters", + author_id="user-1", + tags=["test"], + ) + await repo.add(post) + await db_session.commit() + + result = await repo.get_by_id(post.id) + assert result is not None + assert result.title.value == "Test Title" + assert result.slug.value == "test-title" + + @pytest.mark.asyncio + async def test_get_by_id_returns_post( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="Find Me", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + result = await repo.get_by_id(post.id) + assert result is not None + assert result.id == post.id + + @pytest.mark.asyncio + async def test_get_by_id_returns_none_for_missing(self, repo: SQLAlchemyPostRepository) -> None: + result = await repo.get_by_id(uuid4()) + assert result is None + + @pytest.mark.asyncio + async def test_get_by_slug_returns_post( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="Slug Test", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + result = await repo.get_by_slug(post.slug.value) + assert result is not None + assert result.id == post.id + + @pytest.mark.asyncio + async def test_list_with_pagination( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + for i in range(5): + post = Post.create( + title_str=f"Post {i}", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + result = await repo.get_published(limit=3, offset=0) + assert len(result) == 0 + + @pytest.mark.asyncio + async def test_update_modifies_post( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="Original", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + post.update_title(Title("Updated")) + await repo.update(post) + await db_session.commit() + + result = await repo.get_by_id(post.id) + assert result is not None + assert result.title.value == "Updated" + assert result.slug.value == "updated" + + @pytest.mark.asyncio + async def test_delete_removes_post( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="To Delete", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + await repo.delete(post.id) + await db_session.commit() + + result = await repo.get_by_id(post.id) + assert result is None + + @pytest.mark.asyncio + async def test_slug_exists_checks_uniqueness( + self, repo: SQLAlchemyPostRepository, db_session: AsyncSession + ) -> None: + post = Post.create( + title_str="Unique Slug", + content_str="Content with enough characters for validation", + author_id="user-1", + ) + await repo.add(post) + await db_session.commit() + + assert await repo.slug_exists(post.slug.value) is True + assert await repo.slug_exists("nonexistent-slug") is False diff --git a/tests/unit/infrastructure/database/test_connection.py b/tests/unit/infrastructure/database/test_connection.py new file mode 100644 index 0000000..5eb88c5 --- /dev/null +++ b/tests/unit/infrastructure/database/test_connection.py @@ -0,0 +1,18 @@ +import os +from unittest.mock import patch + +import pytest + +from app.infrastructure.database.connection import init_db + + +class TestInitDB: + @pytest.mark.asyncio + async def test_init_db_skipped_when_skip_env_set(self) -> None: + with patch.dict(os.environ, {"SKIP_INIT_DB": "1"}): + await init_db() + + @pytest.mark.asyncio + async def test_init_db_runs_when_no_env(self) -> None: + with patch.dict(os.environ, {}, clear=True): + await init_db()