Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=devdb
POSTGRES_USER=devdb
POSTGRES_TEST_DB=testdb
POSTGRES_TEST_USER=testdb
POSTGRES_PASSWORD=secret

# Redis
Expand Down
10 changes: 3 additions & 7 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,11 @@ jobs:
POSTGRES_DB: testdb
ports:
- 5432:5432
# needed because the postgres container does not provide a health check
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

steps:
- uses: actions/checkout@v5
- name: Create database schema
run: PGPASSWORD=secret psql -h 127.0.0.1 -d testdb -U panettone -c "CREATE SCHEMA shakespeare; CREATE SCHEMA happy_hog;"
- name: Checkout repository
uses: actions/checkout@v5

- name: Install the latest version of uv
uses: astral-sh/setup-uv@v7
Expand All @@ -58,6 +56,4 @@ jobs:
run: uv run --frozen ruff check .

- name: Test with python ${{ matrix.python-version }}
run: uv run --frozen pytest


run: uv run pytest
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ docker-create-db-migration: ## Create a new alembic database migration. Example
# ====================================================================================
.PHONY: docker-test
docker-test: ## Run project tests
docker compose -f compose.yml -f test-compose.yml run --rm api1 pytest tests --durations=0 -vv
docker compose -f compose.yml run --rm api1 pytest tests --durations=0 -vv

.PHONY: docker-test-snapshot
docker-test-snapshot: ## Run project tests and update snapshots
Expand Down
26 changes: 26 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str
POSTGRES_HOST: str
POSTGRES_DB: str
POSTGRES_TEST_USER: str
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable POSTGRES_TEST_USER is defined in the Settings class but is never used. The test_asyncpg_url method uses POSTGRES_USER instead of POSTGRES_TEST_USER for the username. Either POSTGRES_TEST_USER should be used in test_asyncpg_url, or if it's not needed, it should be removed from the Settings class.

Suggested change
POSTGRES_TEST_USER: str

Copilot uses AI. Check for mistakes.
POSTGRES_TEST_DB: str

@computed_field
@property
Expand Down Expand Up @@ -80,6 +82,30 @@ def asyncpg_url(self) -> PostgresDsn:
path=self.POSTGRES_DB,
)

@computed_field
@property
def test_asyncpg_url(self) -> PostgresDsn:
"""
This is a computed field that generates a PostgresDsn URL for the test database using asyncpg.

The URL is built using the MultiHostUrl.build method, which takes the following parameters:
- scheme: The scheme of the URL. In this case, it is "postgresql+asyncpg".
- username: The username for the Postgres database, retrieved from the POSTGRES_USER environment variable.
- password: The password for the Postgres database, retrieved from the POSTGRES_PASSWORD environment variable.
- host: The host of the Postgres database, retrieved from the POSTGRES_HOST environment variable.
- path: The path of the Postgres test database, retrieved from the POSTGRES_TEST_DB environment variable.

Returns:
PostgresDsn: The constructed PostgresDsn URL for the test database with asyncpg.
"""
return MultiHostUrl.build(
scheme="postgresql+asyncpg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_HOST,
path=self.POSTGRES_TEST_DB,
)

@computed_field
@property
def postgres_url(self) -> PostgresDsn:
Expand Down
27 changes: 27 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
echo=True,
)

test_engine = create_async_engine(
global_settings.test_asyncpg_url.unicode_string(),
future=True,
echo=True,
)

# expire_on_commit=False will prevent attributes from being expired
# after commit.
AsyncSessionFactory = async_sessionmaker(
Expand All @@ -23,6 +29,12 @@
expire_on_commit=False,
)

TestAsyncSessionFactory = async_sessionmaker(
test_engine,
autoflush=False,
expire_on_commit=False,
)


# Dependency
async def get_db() -> AsyncGenerator:
Expand All @@ -38,3 +50,18 @@ async def get_db() -> AsyncGenerator:
if not isinstance(ex, ResponseValidationError):
await logger.aerror(f"Database-related error: {repr(ex)}")
raise # Re-raise to be handled by appropriate handlers


async def get_test_db() -> AsyncGenerator:
async with TestAsyncSessionFactory() as session:
try:
yield session
await session.commit()
except SQLAlchemyError:
# Re-raise SQLAlchemy errors to be handled by the global handler
raise
except Exception as ex:
# Only log actual database-related issues, not response validation
if not isinstance(ex, ResponseValidationError):
await logger.aerror(f"Database-related error: {repr(ex)}")
raise # Re-raise to be handled by appropriate handlers
3 changes: 0 additions & 3 deletions db/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# pull official base image
FROM postgres:17.6-alpine

# run create.sql on init
ADD create.sql /docker-entrypoint-initdb.d

WORKDIR /home/gx/code

COPY shakespeare_chapter.sql /home/gx/code/shakespeare_chapter.sql
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies = [

[tool.uv]
dev-dependencies = [
"ruff==0.14.9",
"ruff==0.14.10",
"devtools[pygments]==0.12.2",
"pyupgrade==3.21.2",
"ipython==9.8.0",
Expand Down
9 changes: 0 additions & 9 deletions test-compose.yml

This file was deleted.

38 changes: 36 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError

from app.database import engine
from app.database import engine, get_db, get_test_db, test_engine
from app.main import app
from app.models.base import Base
from app.redis import get_redis
Expand All @@ -19,15 +21,46 @@
def anyio_backend(request):
return request.param

def _create_db(conn) -> None:
"""Create the test database if it doesn't exist."""
try:
conn.execute(text("CREATE DATABASE testdb"))
except ProgrammingError:
# This might be raised by databases that don't support `IF NOT EXISTS`
# and the schema already exists. You can choose to ignore it.
pass


def _create_db_schema(conn) -> None:
"""Create a database schema if it doesn't exist."""
try:
"""Create a database schema if it doesn't exist."""
conn.execute(text("CREATE SCHEMA IF NOT EXISTS happy_hog"))
conn.execute(text("CREATE SCHEMA IF NOT EXISTS shakespeare"))
except ProgrammingError:
# This might be raised by databases that don't support `IF NOT EXISTS`
# and the schema already exists. You can choose to ignore it.
pass


@pytest.fixture(scope="session")
async def start_db():
async with engine.begin() as conn:
# The `engine` is configured for the default 'postgres' database.
# We connect to it and create the test database.
# A transaction block is not used, as CREATE DATABASE cannot run inside it.
async with engine.connect() as conn:
await conn.execute(text("COMMIT")) # Ensure we're not in a transaction
await conn.run_sync(_create_db)

# Now, connect to the newly created `testdb` with `test_engine`
async with test_engine.begin() as conn:
await conn.run_sync(_create_db_schema)
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# for AsyncEngine created in function scope, close and
# clean-up pooled connections
await engine.dispose()
await test_engine.dispose()


@pytest.fixture(scope="session")
Expand All @@ -40,5 +73,6 @@ async def client(start_db) -> AsyncGenerator[AsyncClient, Any]: # noqa: ARG001
headers={"Content-Type": "application/json"},
transport=transport,
) as test_client:
app.dependency_overrides[get_db] = get_test_db
app.redis = await get_redis()
yield test_client
46 changes: 23 additions & 23 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.