Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
13 changes: 13 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,17 @@ def asyncpg_url(self) -> PostgresDsn:
path=self.POSTGRES_DB,
)

@computed_field
@property
def test_asyncpg_url(self) -> PostgresDsn:
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
9 changes: 0 additions & 9 deletions test-compose.yml

This file was deleted.

39 changes: 37 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from collections.abc import AsyncGenerator
from types import SimpleNamespace
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 import SimpleNamespace from the types module is not used anywhere in this file. This unused import should be removed to keep the code clean.

Suggested change
from types import SimpleNamespace

Copilot uses AI. Check for mistakes.
from typing import Any

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, test_engine, get_test_db, get_db
from app.main import app
from app.models.base import Base
from app.redis import get_redis
Expand All @@ -19,15 +22,46 @@
def anyio_backend(request):
return request.param

def _create_db(conn) -> None:
"""Create a database schema if it doesn't exist."""
Comment thread
grillazz marked this conversation as resolved.
Outdated
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:
conn.execute(text("CREATE SCHEMA happy_hog"))
conn.execute(text("CREATE SCHEMA 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.execute(text("COMMIT")) # Ensure we're not in a transaction
Comment thread
grillazz marked this conversation as resolved.
Outdated
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 +74,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
Loading