Skip to content

Commit c711e79

Browse files
Merge pull request #130 from Promptly-Technologies-LLC/delay-env
Delay env loading until after app/test setup
2 parents a7646b1 + ec8bc28 commit c711e79

File tree

13 files changed

+626
-628
lines changed

13 files changed

+626
-628
lines changed

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ SECRET_KEY=
55
BASE_URL=http://localhost:8000
66

77
# Database
8-
DB_USER=
9-
DB_PASSWORD=
10-
DB_HOST=localhost
8+
DB_USER=postgres
9+
DB_PASSWORD=postgres
10+
DB_HOST=127.0.0.1
1111
DB_PORT=5432
1212
DB_NAME=
1313

.github/workflows/test.yml

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
fail-fast: false
1313
matrix:
14-
python-version: ["3.12"]
14+
python-version: ["3.13"]
1515
os: [ubuntu-latest]
1616

1717
runs-on: ${{ matrix.os }}
@@ -20,7 +20,7 @@ jobs:
2020
postgres:
2121
image: postgres:latest
2222
env:
23-
POSTGRES_DB: test_db
23+
POSTGRES_DB: db
2424
POSTGRES_USER: postgres
2525
POSTGRES_PASSWORD: postgres
2626
ports:
@@ -45,29 +45,6 @@ jobs:
4545
- name: Install project
4646
run: uv sync --all-extras --dev
4747

48-
- name: Set env variables for pytest
49-
run: |
50-
echo "DB_USER=postgres" >> $GITHUB_ENV
51-
echo "DB_PASSWORD=postgres" >> $GITHUB_ENV
52-
echo "DB_HOST=127.0.0.1" >> $GITHUB_ENV
53-
echo "DB_PORT=5432" >> $GITHUB_ENV
54-
echo "DB_NAME=test_db" >> $GITHUB_ENV
55-
echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
56-
echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV
57-
echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV
58-
echo "[email protected]" >> $GITHUB_ENV
59-
60-
- name: Verify environment variables
61-
run: |
62-
echo "Checking if required environment variables are set..."
63-
[ -n "$DB_USER" ] && \
64-
[ -n "$DB_PASSWORD" ] && \
65-
[ -n "$DB_HOST" ] && \
66-
[ -n "$DB_PORT" ] && \
67-
[ -n "$DB_NAME" ] && \
68-
[ -n "$SECRET_KEY" ] && \
69-
[ -n "$RESEND_API_KEY" ]
70-
7148
- name: Run type checking with mypy
7249
run: uv run mypy .
7350

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ dependencies = [
1313
"pyjwt<3.0.0,>=2.10.1",
1414
"jinja2<4.0.0,>=3.1.4",
1515
"uvicorn<1.0.0,>=0.32.0",
16-
"psycopg2<3.0.0,>=2.9.10",
1716
"pydantic[email]<3.0.0,>=2.9.2",
1817
"python-multipart<1.0.0,>=0.0.17",
1918
"python-dotenv<2.0.0,>=1.0.1",
2019
"resend<3.0.0,>=2.4.0",
2120
"bcrypt<5.0.0,>=4.2.0",
2221
"fastapi<1.0.0,>=0.115.5",
2322
"pillow>=11.0.0",
23+
"psycopg2-binary>=2.9.10",
2424
]
2525

2626
[dependency-groups]
@@ -31,5 +31,5 @@ dev = [
3131
"notebook<8.0.0,>=7.2.2",
3232
"pytest<9.0.0,>=8.3.3",
3333
"sqlalchemy-schemadisplay<3.0,>=2.0",
34-
"mypy>=1.15.0",
34+
"mypy>=1.18.2",
3535
]

routers/core/account.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -375,20 +375,24 @@ async def login(
375375

376376
# Process the invitation
377377
try:
378-
logger.info(f"Processing invitation {invitation.id} for user {account.user.id} during login.")
379-
process_invitation(invitation, account.user, session)
380-
session.commit()
381-
# Set redirect to the organization page
382-
redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id)
383-
logger.info(f"Redirecting user {account.user.id} to organization {invitation.organization_id} after accepting invitation {invitation.id}.")
378+
if account.user and account.user.id:
379+
logger.info(f"Processing invitation {invitation.id} for user {account.user.id} during login.")
380+
process_invitation(invitation, account.user, session)
381+
session.commit()
382+
# Set redirect to the organization page
383+
redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id)
384+
logger.info(f"Redirecting user {account.user.id} to organization {invitation.organization_id} after accepting invitation {invitation.id}.")
385+
else:
386+
logger.error("User has no ID during invitation processing.")
387+
raise DataIntegrityError(resource="User ID")
384388
except Exception as e:
385-
logger.error(
386-
f"Error processing invitation {invitation.id} for user {account.user.id} during login: {e}",
387-
exc_info=True
388-
)
389-
session.rollback()
390-
# Raise the specific invitation processing error
391-
raise InvitationProcessingError()
389+
logger.error(
390+
f"Error processing invitation during login: {e}",
391+
exc_info=True
392+
)
393+
session.rollback()
394+
# Raise the specific invitation processing error
395+
raise InvitationProcessingError()
392396

393397
else:
394398
logger.info(f"Standard login for account {account.email}. Redirecting to dashboard.")

tests/conftest.py

Lines changed: 39 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import pytest
2+
import os
23
from typing import Generator
34
from sqlmodel import create_engine, Session, select
4-
from sqlalchemy import Engine
55
from fastapi.testclient import TestClient
66
from dotenv import load_dotenv
7-
from utils.core.db import get_connection_url, tear_down_db, set_up_db, create_default_roles
8-
from utils.core.models import User, PasswordResetToken, EmailUpdateToken, Organization, Role, Account, Invitation
7+
from utils.core.db import get_connection_url, tear_down_db, set_up_db, create_default_roles, ensure_database_exists
8+
from utils.core.models import User, Organization, Role, Account, Invitation
99
from utils.core.auth import get_password_hash, create_access_token, create_refresh_token
1010
from main import app
1111
from datetime import datetime, UTC, timedelta
1212

13-
# Load environment variables
14-
load_dotenv(override=True)
15-
1613
# Define a custom exception for test setup errors
1714
class SetupError(Exception):
1815
"""Exception raised for errors in the test setup process."""
@@ -21,29 +18,41 @@ def __init__(self, message="An error occurred during test setup"):
2118
super().__init__(self.message)
2219

2320

24-
@pytest.fixture(scope="session")
25-
def engine() -> Engine:
21+
@pytest.fixture
22+
def env_vars(monkeypatch):
23+
load_dotenv()
24+
25+
# monkeypatch remaining env vars
26+
with monkeypatch.context() as m:
27+
# Get valid db user, password, host, and port from env
28+
m.setenv("DB_HOST", os.getenv("DB_HOST", "127.0.0.1"))
29+
m.setenv("DB_PORT", os.getenv("DB_PORT", "5432"))
30+
m.setenv("DB_USER", os.getenv("DB_USER", "postgres"))
31+
m.setenv("DB_PASSWORD", os.getenv("DB_PASSWORD", "postgres"))
32+
m.setenv("SECRET_KEY", "testsecretkey")
33+
m.setenv("HOST_NAME", "Test Organization")
34+
m.setenv("DB_NAME", "qual2db4-test-db")
35+
m.setenv("RESEND_API_KEY", "test")
36+
m.setenv("EMAIL_FROM", "[email protected]")
37+
m.setenv("QUALTRICS_BASE_URL", "test")
38+
m.setenv("QUALTRICS_API_TOKEN", "test")
39+
m.setenv("BASE_URL", "http://localhost:8000")
40+
yield
41+
42+
43+
@pytest.fixture
44+
def engine(env_vars):
2645
"""
2746
Create a new SQLModel engine for the test database.
2847
Use PostgreSQL for testing to match production environment.
2948
"""
3049
# Use PostgreSQL for testing to match production environment
50+
ensure_database_exists(get_connection_url())
3151
engine = create_engine(get_connection_url())
32-
return engine
52+
set_up_db(drop=True)
3353

54+
yield engine
3455

35-
@pytest.fixture(scope="session", autouse=True)
36-
def set_up_database(engine) -> Generator[None, None, None]:
37-
"""
38-
Set up the test database before running the test suite.
39-
Drop all tables and recreate them to ensure a clean state.
40-
"""
41-
# Drop and recreate all tables using the helpers from db.py
42-
tear_down_db()
43-
set_up_db(drop=False)
44-
45-
yield
46-
4756
# Clean up after tests
4857
tear_down_db()
4958

@@ -57,20 +66,7 @@ def session(engine) -> Generator[Session, None, None]:
5766
yield session
5867

5968

60-
@pytest.fixture(autouse=True)
61-
def clean_db(session: Session) -> None:
62-
"""
63-
Cleans up the database tables before each test.
64-
"""
65-
# Don't delete permissions as they are required for tests
66-
for model in (PasswordResetToken, EmailUpdateToken, User, Role, Organization, Account):
67-
for record in session.exec(select(model)).all():
68-
session.delete(record)
69-
70-
session.commit()
71-
72-
73-
@pytest.fixture()
69+
@pytest.fixture
7470
def test_account(session: Session) -> Account:
7571
"""
7672
Creates a test account in the database.
@@ -85,7 +81,7 @@ def test_account(session: Session) -> Account:
8581
return account
8682

8783

88-
@pytest.fixture()
84+
@pytest.fixture
8985
def test_user(session: Session, test_account: Account) -> User:
9086
"""
9187
Creates a test user in the database linked to the test account.
@@ -103,7 +99,7 @@ def test_user(session: Session, test_account: Account) -> User:
10399
return user
104100

105101

106-
@pytest.fixture()
102+
@pytest.fixture
107103
def unauth_client(session: Session) -> Generator[TestClient, None, None]:
108104
"""
109105
Provides a TestClient instance without authentication.
@@ -112,7 +108,7 @@ def unauth_client(session: Session) -> Generator[TestClient, None, None]:
112108
yield client
113109

114110

115-
@pytest.fixture()
111+
@pytest.fixture
116112
def auth_client(session: Session, test_account: Account, test_user: User) -> Generator[TestClient, None, None]:
117113
"""
118114
Provides a TestClient instance with valid authentication tokens.
@@ -136,13 +132,13 @@ def test_organization(session: Session) -> Organization:
136132
session.add(organization)
137133
session.flush()
138134

139-
if organization.id is None:
135+
if organization.id:
136+
# Use the utility function to create default roles and assign permissions
137+
# This function handles the commit internally
138+
create_default_roles(session, organization.id, check_first=False)
139+
else:
140140
pytest.fail("Failed to get organization ID after flush")
141141

142-
# Use the utility function to create default roles and assign permissions
143-
# This function handles the commit internally
144-
create_default_roles(session, organization.id, check_first=False)
145-
146142
return organization
147143

148144

tests/routers/core/test_account.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import pytest
21
from fastapi.testclient import TestClient
32
from starlette.datastructures import URLPath
43
from sqlmodel import Session, select
@@ -16,9 +15,6 @@
1615
get_password_hash
1716
)
1817

19-
# --- Fixture setup ---
20-
21-
2218
# --- API Endpoint Tests ---
2319

2420

@@ -124,7 +120,7 @@ def test_password_reset_flow(unauth_client: TestClient, session: Session, test_a
124120

125121
# Verify content
126122
assert call_args["to"] == [test_account.email]
127-
assert call_args["from"] == "noreply@promptlytechnologies.com"
123+
assert call_args["from"] == "test@example.com"
128124
assert "Password Reset Request" in call_args["subject"]
129125
assert "reset_password" in call_args["html"]
130126

@@ -259,7 +255,7 @@ def test_request_email_update_success(auth_client: TestClient, test_account: Acc
259255

260256
# Verify email content
261257
assert call_args["to"] == [test_account.email]
262-
assert call_args["from"] == "noreply@promptlytechnologies.com"
258+
assert call_args["from"] == "test@example.com"
263259
assert "Confirm Email Update" in call_args["subject"]
264260
assert "confirm_email_update" in call_args["html"]
265261
assert new_email in call_args["html"]

tests/utils/test_auth.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_password_hashing() -> None:
3737
assert not verify_password("wrong_password", hashed)
3838

3939

40-
def test_token_creation_and_validation() -> None:
40+
def test_token_creation_and_validation(env_vars) -> None:
4141
data = {"sub": "[email protected]"}
4242

4343
# Test access token
@@ -55,21 +55,21 @@ def test_token_creation_and_validation() -> None:
5555
assert decoded["type"] == "refresh"
5656

5757

58-
def test_expired_token() -> None:
58+
def test_expired_token(env_vars) -> None:
5959
data = {"sub": "[email protected]"}
6060
expired_delta = timedelta(minutes=-10)
6161
expired_token = create_access_token(data, expired_delta)
6262
decoded = validate_token(expired_token, "access")
6363
assert decoded is None
6464

6565

66-
def test_invalid_token_type() -> None:
66+
def test_invalid_token_type(env_vars) -> None:
6767
data = {"sub": "[email protected]"}
6868
access_token = create_access_token(data)
6969
decoded = validate_token(access_token, "refresh")
7070
assert decoded is None
7171

72-
def test_password_reset_url_generation() -> None:
72+
def test_password_reset_url_generation(env_vars) -> None:
7373
"""
7474
Tests that the password reset URL is correctly formatted and contains
7575
the required query parameters.
@@ -151,7 +151,7 @@ def test_password_pattern() -> None:
151151
password = "aA1" * 3
152152
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
153153

154-
def test_email_update_url_generation() -> None:
154+
def test_email_update_url_generation(env_vars) -> None:
155155
"""
156156
Tests that the email update confirmation URL is correctly formatted and contains
157157
the required query parameters.

tests/utils/test_db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from utils.core.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions
1313
from tests.conftest import SetupError
1414

15-
def test_get_connection_url():
15+
def test_get_connection_url(env_vars):
1616
"""Test that get_connection_url returns a valid URL object"""
1717
url = get_connection_url()
1818
assert url.drivername == "postgresql"

0 commit comments

Comments
 (0)