Skip to content

Commit 892918d

Browse files
Delay env var loading till after setup
1 parent a7646b1 commit 892918d

File tree

7 files changed

+67
-80
lines changed

7 files changed

+67
-80
lines changed

.github/workflows/test.yml

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,26 +47,18 @@ jobs:
4747

4848
- name: Set env variables for pytest
4949
run: |
50-
echo "DB_USER=postgres" >> $GITHUB_ENV
51-
echo "DB_PASSWORD=postgres" >> $GITHUB_ENV
5250
echo "DB_HOST=127.0.0.1" >> $GITHUB_ENV
5351
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
52+
echo "DB_USER=postgres" >> $GITHUB_ENV
53+
echo "DB_PASSWORD=postgres" >> $GITHUB_ENV
5954
6055
- name: Verify environment variables
6156
run: |
6257
echo "Checking if required environment variables are set..."
63-
[ -n "$DB_USER" ] && \
64-
[ -n "$DB_PASSWORD" ] && \
6558
[ -n "$DB_HOST" ] && \
6659
[ -n "$DB_PORT" ] && \
67-
[ -n "$DB_NAME" ] && \
68-
[ -n "$SECRET_KEY" ] && \
69-
[ -n "$RESEND_API_KEY" ]
60+
[ -n "$DB_USER" ] && \
61+
[ -n "$DB_PASSWORD" ]
7062
7163
- name: Run type checking with mypy
7264
run: uv run mypy .

tests/conftest.py

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import pytest
2+
import os
23
from typing import Generator
34
from sqlmodel import create_engine, Session, select
45
from sqlalchemy import Engine
56
from fastapi.testclient import TestClient
67
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.db import get_connection_url, tear_down_db, set_up_db, create_default_roles, ensure_database_exists
89
from utils.core.models import User, PasswordResetToken, EmailUpdateToken, Organization, Role, Account, Invitation
910
from utils.core.auth import get_password_hash, create_access_token, create_refresh_token
1011
from main import app
1112
from datetime import datetime, UTC, timedelta
1213

13-
# Load environment variables
14-
load_dotenv(override=True)
15-
1614
# Define a custom exception for test setup errors
1715
class SetupError(Exception):
1816
"""Exception raised for errors in the test setup process."""
@@ -21,29 +19,44 @@ def __init__(self, message="An error occurred during test setup"):
2119
super().__init__(self.message)
2220

2321

24-
@pytest.fixture(scope="session")
25-
def engine() -> Engine:
22+
@pytest.fixture
23+
def env_vars(monkeypatch):
24+
# Get valid db user, password, host, and port from .env
25+
load_dotenv()
26+
os.environ["DB_HOST"] = os.getenv("DB_HOST", "localhost")
27+
os.environ["DB_PORT"] = os.getenv("DB_PORT", "5432")
28+
os.environ["DB_USER"] = os.getenv("DB_USER", "appuser")
29+
os.environ["DB_PASSWORD"] = os.getenv("DB_PASSWORD", "testpassword")
30+
31+
# monkeypatch remaining env vars
32+
with monkeypatch.context() as m:
33+
m.setenv("SECRET_KEY", "testsecretkey")
34+
m.setenv("HOST_NAME", "Test Organization")
35+
m.setenv("DB_HOST", os.getenv("DB_HOST", "localhost"))
36+
m.setenv("DB_PORT", os.getenv("DB_PORT", "5432"))
37+
m.setenv("DB_USER", os.getenv("DB_USER", "appuser"))
38+
m.setenv("DB_PASSWORD", os.getenv("DB_PASSWORD", "testpassword"))
39+
m.setenv("DB_NAME", "qual2db4-test-db")
40+
m.setenv("RESEND_API_KEY", "test")
41+
m.setenv("EMAIL_FROM", "[email protected]")
42+
m.setenv("QUALTRICS_BASE_URL", "test")
43+
m.setenv("QUALTRICS_API_TOKEN", "test")
44+
yield
45+
46+
47+
@pytest.fixture
48+
def engine(env_vars) -> Engine:
2649
"""
2750
Create a new SQLModel engine for the test database.
2851
Use PostgreSQL for testing to match production environment.
2952
"""
3053
# Use PostgreSQL for testing to match production environment
54+
ensure_database_exists(get_connection_url())
3155
engine = create_engine(get_connection_url())
32-
return engine
56+
set_up_db(drop=True)
3357

58+
yield engine
3459

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-
4760
# Clean up after tests
4861
tear_down_db()
4962

@@ -57,20 +70,7 @@ def session(engine) -> Generator[Session, None, None]:
5770
yield session
5871

5972

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()
73+
@pytest.fixture
7474
def test_account(session: Session) -> Account:
7575
"""
7676
Creates a test account in the database.
@@ -85,7 +85,7 @@ def test_account(session: Session) -> Account:
8585
return account
8686

8787

88-
@pytest.fixture()
88+
@pytest.fixture
8989
def test_user(session: Session, test_account: Account) -> User:
9090
"""
9191
Creates a test user in the database linked to the test account.
@@ -103,7 +103,7 @@ def test_user(session: Session, test_account: Account) -> User:
103103
return user
104104

105105

106-
@pytest.fixture()
106+
@pytest.fixture
107107
def unauth_client(session: Session) -> Generator[TestClient, None, None]:
108108
"""
109109
Provides a TestClient instance without authentication.
@@ -112,7 +112,7 @@ def unauth_client(session: Session) -> Generator[TestClient, None, None]:
112112
yield client
113113

114114

115-
@pytest.fixture()
115+
@pytest.fixture
116116
def auth_client(session: Session, test_account: Account, test_user: User) -> Generator[TestClient, None, None]:
117117
"""
118118
Provides a TestClient instance with valid authentication tokens.

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"]

utils/core/auth.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import uuid
66
import logging
77
import resend
8-
from dotenv import load_dotenv
98
from sqlmodel import Session, select
109
from bcrypt import gensalt, hashpw, checkpw
1110
from datetime import UTC, datetime, timedelta
@@ -15,9 +14,6 @@
1514
from fastapi import Cookie
1615
from utils.core.models import PasswordResetToken, EmailUpdateToken, Account
1716

18-
load_dotenv(override=True)
19-
resend.api_key = os.environ["RESEND_API_KEY"]
20-
2117
logger = logging.getLogger(__name__)
2218
logger.setLevel(logging.DEBUG)
2319
logger.addHandler(logging.StreamHandler())
@@ -27,7 +23,6 @@
2723

2824

2925
templates = Jinja2Templates(directory="templates")
30-
SECRET_KEY = os.getenv("SECRET_KEY")
3126
ALGORITHM = "HS256"
3227
ACCESS_TOKEN_EXPIRE_MINUTES = 30
3328
REFRESH_TOKEN_EXPIRE_DAYS = 30
@@ -120,7 +115,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
120115
expire = datetime.now(
121116
UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
122117
to_encode.update({"exp": expire})
123-
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
118+
encoded_jwt = jwt.encode(to_encode, os.getenv("SECRET_KEY"), algorithm=ALGORITHM)
124119
return encoded_jwt
125120

126121

@@ -132,13 +127,13 @@ def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None)
132127
else:
133128
expire = datetime.now(UTC) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
134129
to_encode.update({"exp": expire})
135-
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
130+
encoded_jwt = jwt.encode(to_encode, os.getenv("SECRET_KEY"), algorithm=ALGORITHM)
136131
return encoded_jwt
137132

138133

139134
def validate_token(token: str, token_type: str = "access") -> Optional[dict]:
140135
try:
141-
decoded_token = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
136+
decoded_token = jwt.decode(token, os.getenv("SECRET_KEY"), algorithms=[ALGORITHM])
142137

143138
# Check if the token has expired
144139
if decoded_token["exp"] < datetime.now(UTC).timestamp():
@@ -182,7 +177,7 @@ def send_reset_email(email: str, session: Session) -> None:
182177
.where(
183178
PasswordResetToken.account_id == account.id,
184179
PasswordResetToken.expires_at > datetime.now(UTC),
185-
PasswordResetToken.used == False
180+
not PasswordResetToken.used
186181
)
187182
).first()
188183

@@ -204,6 +199,7 @@ def send_reset_email(email: str, session: Session) -> None:
204199
"emails/reset_email.html")
205200
html_content: str = template.render({"reset_url": reset_url})
206201

202+
resend.api_key = os.getenv("RESEND_API_KEY")
207203
params: resend.Emails.SendParams = {
208204
"from": os.getenv("EMAIL_FROM", ""),
209205
"to": [email],
@@ -242,7 +238,7 @@ def send_email_update_confirmation(
242238
.where(
243239
EmailUpdateToken.account_id == account_id,
244240
EmailUpdateToken.expires_at > datetime.now(UTC),
245-
EmailUpdateToken.used == False
241+
not EmailUpdateToken.used
246242
)
247243
).first()
248244

@@ -266,6 +262,7 @@ def send_email_update_confirmation(
266262
"new_email": new_email
267263
})
268264

265+
resend.api_key = os.getenv("RESEND_API_KEY")
269266
params: resend.Emails.SendParams = {
270267
"from": os.getenv("EMAIL_FROM", ""),
271268
"to": [current_email],

utils/core/db.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import os
22
import logging
33
from typing import Union, Sequence
4-
from dotenv import load_dotenv
54
from sqlalchemy.engine import URL
6-
from sqlmodel import create_engine, Session, SQLModel, select
5+
from sqlmodel import create_engine, Session, SQLModel, select, text
76
from utils.core.models import Role, Permission, RolePermissionLink
87
from utils.core.enums import ValidPermissions
98

10-
# Load environment variables from a .env file
11-
load_dotenv()
12-
139
# Set up a logger for error reporting
1410
logger = logging.getLogger("uvicorn.error")
1511

@@ -23,6 +19,19 @@
2319
# --- Database connection functions ---
2420

2521

22+
def ensure_database_exists(url: URL) -> None:
23+
dbname = url.database
24+
server_url = url.set(database="postgres")
25+
engine = create_engine(server_url, isolation_level="AUTOCOMMIT")
26+
with engine.connect() as conn:
27+
exists = conn.execute(
28+
text("SELECT 1 FROM pg_database WHERE datname = :n"),
29+
{"n": dbname},
30+
).scalar()
31+
if not exists:
32+
conn.execute(text(f'CREATE DATABASE "{dbname}"'))
33+
34+
2635
def get_connection_url() -> URL:
2736
"""
2837
Constructs a SQLModel URL object for connecting to the PostgreSQL database.
@@ -49,10 +58,6 @@ def get_connection_url() -> URL:
4958
return database_url
5059

5160

52-
# Create the database engine using the connection URL
53-
engine = create_engine(get_connection_url())
54-
55-
5661
def assign_permissions_to_role(
5762
session: Session,
5863
role: Role,

utils/core/dependencies.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
validate_token, create_access_token, create_refresh_token,
99
oauth2_scheme_cookie, verify_password
1010
)
11-
from utils.core.db import engine
11+
from utils.core.db import create_engine, get_connection_url
1212
from utils.core.models import User, Role, PasswordResetToken, EmailUpdateToken, Account
1313
from exceptions.http_exceptions import AuthenticationError, CredentialsError, DataIntegrityError
1414
from exceptions.exceptions import NeedsNewTokens
@@ -21,6 +21,7 @@ def get_session() -> Generator[Session, None, None]:
2121
Yields:
2222
Session: A SQLModel session object for database operations.
2323
"""
24+
engine = create_engine(get_connection_url())
2425
with Session(engine) as session:
2526
yield session
2627

utils/core/invitations.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import os
22
from logging import getLogger, DEBUG
33
import resend
4-
from dotenv import load_dotenv
54
from sqlmodel import Session
65
from jinja2.environment import Template
76
from fastapi.templating import Jinja2Templates
@@ -10,10 +9,6 @@
109
from exceptions.exceptions import EmailSendFailedError
1110
from exceptions.http_exceptions import DataIntegrityError
1211

13-
# Load environment variables
14-
load_dotenv(override=True)
15-
resend.api_key = os.environ.get("RESEND_API_KEY")
16-
BASE_URL = os.getenv("BASE_URL", "")
1712

1813
# Setup logging
1914
logger = getLogger("uvicorn.error")
@@ -33,7 +28,7 @@ def generate_invitation_link(token: str) -> str:
3328
Returns:
3429
The complete URL for accepting the invitation.
3530
"""
36-
return f"{BASE_URL}/invitations/accept?token={token}"
31+
return f"{os.getenv('BASE_URL')}/invitations/accept?token={token}"
3732

3833

3934
def send_invitation_email(invitation: Invitation, session: Session) -> None:
@@ -45,6 +40,7 @@ def send_invitation_email(invitation: Invitation, session: Session) -> None:
4540
session: The database session (used here primarily for potential future needs or consistency,
4641
though direct DB access might be minimal if invitation object is pre-loaded).
4742
"""
43+
resend.api_key = os.getenv("RESEND_API_KEY")
4844
if not resend.api_key:
4945
logger.error("Resend API key is not configured. Cannot send invitation email.")
5046
raise EmailSendFailedError("Resend API key is not configured.")

0 commit comments

Comments
 (0)