Skip to content
Open
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
187 changes: 187 additions & 0 deletions JOURNAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
## Week 7 — Issue selection

**Issue link:** https://github.com/ascherj/pathreview/issues/154

**Issue title:** Health check DB probe passes a raw SQL string, which fails under SQLAlchemy 2.x

**Tier:** [x] Tier 1 [ ] Tier 2 [ ] Tier 3

**Problem summary:**
The database health check in `api/routes/health.py` calls
`db.execute("SELECT 1")` with a bare SQL string. SQLAlchemy 2.x requires
literal SQL to be explicitly wrapped in `sqlalchemy.text()` before
execution, so this call raises an `ArgumentError` instead of running the
query. As a result, the `/health` endpoint reports the database as
unhealthy even when Postgres is fully reachable, which is misleading for
anyone or any monitoring tool relying on that endpoint. A successful fix
imports `text` from `sqlalchemy` and wraps the query string, so the probe
executes correctly and `/health` reflects the database's true status.

**Selection notes:**
I chose Tier 1 since this is my first time contributing to a large,
unfamiliar codebase. I confirmed the bug directly by opening
`api/routes/health.py` and reading the `health_check()` function in full:
the Postgres check calls `db.execute("SELECT 1")` with no `text` import
anywhere in the file, matching the issue exactly. No `test_health.py`
exists yet in `tests/unit/`, so I'll be writing the first test for this
route, modeled on the test conventions used elsewhere in the project. I
also noticed this same file contains a second, unrelated known bug
(#155, an invalid `settings.redis_host` reference in the Redis check
block) — I'm not touching that, but it confirms I read the whole
function, not just the one line I'm fixing. The fix itself is a one-line
change plus one new test, comfortably within the 3–6 hour Tier 1 estimate
for Weeks 8–9. No blockers or open dependencies were found on the issue.


**Branch name:** fix/154-health-check-sqlalchemy-text

**Setup confirmation:** [x] App runs locally at localhost:5173

**Cohort ledger:** [x] Issue added to cohort ledger

## Week 8 — Reproduction & solution planning

**Reproduction summary:**
Ran the app locally with `make run` (Postgres and Redis containers
confirmed healthy via `docker compose ps`). Called `GET /health` via
curl and observed a 503 response with `"postgres":"unhealthy"`. Server
logs confirmed the exact root cause:
`error="Textual SQL expression 'SELECT 1' should be explicitly declared
as text('SELECT 1')"`. The logs also show a separate, unrelated error
(`'Settings' object has no attribute 'redis_host'`, issue #155) in the
Redis check — confirming my issue is isolated to the Postgres check only.
**Reproduction commit link:** https://github.com/laurale31/pathreview/commit/a57447b

**PLAN.md link:** https://github.com/laurale31/pathreview/blob/fix/154-health-check-sqlalchemy-text/PLAN.md

**Blockers or open questions:**
No route-level test conventions exist yet in this project (tests/integration/
exists but is empty, and tests/unit/ has no test_health.py). Planning to
model my Week 9 test on test_review_service.py's AsyncMock db-session
fixture pattern instead, calling health_check() directly rather than
through an HTTP client.

## Week 9 — Solution building & PR submission

### Check-in 1 (mid-week)

**Current progress:**
Completed PLAN.md sub-tasks 1–3: added the `text` import to
`api/routes/health.py` and wrapped the `SELECT 1` query in `text()`.
Verified via curl that `/health` now reports `"postgres": "healthy"`
instead of raising the SQLAlchemy `ArgumentError`. Confirmed via a
`git stash` comparison that existing ruff/mypy issues in `health.py`
(1 ruff error, 11 mypy errors) predate my change and aren't something
I introduced.

**Next steps:**
Writing `tests/unit/test_health.py` (sub-task 4), modeled on
`test_review_service.py`'s `AsyncMock` fixture pattern. Then running
the full `make check` and `make test-unit` suite to document the
pre-existing failure baseline before opening the PR.

**Blockers:**
None currently — the Redis check in the same function has a separate,
known bug (#155) that always fails, so my test needs to account for
`health_check()` always raising `HTTPException` regardless of my fix.
Not a blocker, just something to design the test around.

### Check-in 2 (end of week)

**PR link:** https://github.com/ascherj/pathreview/pull/446

**Branch:** fix/154-health-check-sqlalchemy-text

**What you built:**
Fixed the `/health` endpoint's Postgres probe, which was passing a raw
SQL string to `execute()` — incompatible with SQLAlchemy 2.x. Wrapped
the query in `text()` so the health check correctly reports Postgres as
healthy when it's reachable, instead of always failing.

**Tests added or updated:**
Added `tests/unit/test_health.py` with two tests: one confirming the
Postgres check reports "healthy" when `execute()` succeeds, and one
confirming it reports "unhealthy" when `execute()` raises an exception.
Both tests account for `health_check()` still raising `HTTPException`
overall due to the separate, pre-existing Redis bug (#155), verifying
the Postgres field specifically is correctly isolated from that failure.

**Self-review confirmation:** [x] make check passes [x] make test-unit passes
(both confirmed to introduce no new failures beyond the documented
pre-existing baseline — see PR #446 description for full details)

## Week 10 — Iteration & reflection

### Reviewer feedback

**Feedback received:** [ ] Yes [x] No — still awaiting review

**Summary of feedback:**
No review has come in. Per the course note, reviewer feedback is not a
feature in Summer 2026 — this section is left as-is per instructions.

**How you responded:**
N/A — no feedback received.

---

### Reflection

**What was harder than you expected?**
Environment setup ate way more time than I expected, and not because
of the actual bug I was fixing. I accidentally cloned the repo a second
time while already inside my first clone, which created two nested
folders both named `pathreview`. I didn't notice for almost two weeks —
I'd been running Docker, installing dependencies, and testing my fix
inside the wrong copy, while all my git commits were landing in the
other one. Since both folders had the exact same name, my terminal
prompt gave me zero visual signal I was in the wrong place. I only
caught it when `docker compose ps` and `git log` gave inconsistent
answers about what was actually running. It taught me to always run
`pwd` when something feels "off," instead of assuming my last `cd`
worked the way I thought it did.

**What did you learn about working in a large codebase?**
The biggest shift was realizing a codebase can have multiple, unrelated
bugs living in the same function. My issue (#154) and issue #155 were
both inside the same `health_check()` function, but I had to be
disciplined about touching only my bug and explicitly documenting that
I saw the other one without fixing it. I also learned that "does the
test suite pass" isn't a yes/no question in a real project — I ran
`make test-unit` and got 53 failing tests that had nothing to do with
my change. Instead of panicking, I had to prove (via a `git stash`
comparison of before/after) that none of those failures were caused by
me, then document that clearly for a reviewer instead of just hoping
nobody would notice.

**How did AI tools help — and where did they fall short?**
AI was most useful for pattern-matching — helping me find an existing
test file (`test_review_service.py`) to model my new test on, and for
walking through what a SQLAlchemy 2.x `text()` error actually means
that first time I saw it in a traceback. It also caught things I
missed, like when my PLAN.md claimed `tests/integration/` didn't exist
when it actually did (just empty) — a factual error I would have
otherwise committed and had graded as-is.

Where it fell short: it couldn't run my terminal for me or notice I
was in the wrong directory — I had to actually run `pwd` and paste the
output before either of us could diagnose the double-clone problem. It
also couldn't tell me whether my test's mocking approach was "correct"
in some abstract sense — I had to actually run `pytest` and read the
real pass/fail output myself to know if it worked.

**What would you do differently if you started over?**
I'd run `pwd` immediately after every `cd` and `git clone` for the
first few sessions, until I trusted my mental model of the folder
structure. I'd also run the full `make check`/`make test-unit` baseline
on the untouched codebase on day one, before writing any code — I did
this eventually, but doing it first would have saved me from wondering
whether failures I saw later were my fault.

**What are you most proud of from this module?**
Catching the tests/integration/ mistake in my own PLAN.md before it
got graded. It would have been easy to leave that inaccurate claim in
since it "sounded right" from partial exploration — going back and
actually running `ls tests/integration/` to verify it, and correcting
the document, felt like the most "real engineering" moment of the
whole module.
87 changes: 87 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
## Solution plan

**Issue:** Health check DB probe passes a raw SQL string, which fails under SQLAlchemy 2.x
https://github.com/ascherj/pathreview/issues/154

### Understand
Root cause: `health_check()` in `api/routes/health.py` calls
`await db.execute("SELECT 1")` with a bare Python string. SQLAlchemy 2.x
removed implicit string-to-SQL coercion for safety reasons — literal SQL
must be explicitly wrapped in `sqlalchemy.text()`. Because `text` is
never imported in this file, the call raises an error on every request.
Confirmed via local reproduction — the server logs show:
`error="Textual SQL expression 'SELECT 1' should be explicitly declared
as text('SELECT 1')"`.

Expected behavior: `/health` should execute the query successfully and
report `"postgres": "healthy"` when the database is reachable.
Actual behavior: the query always raises, so `/health` reports
`"postgres": "unhealthy"` and returns a 503, even when Postgres is
fully up and reachable (confirmed via `docker compose ps` showing the
`db` container as healthy).

### Map
- `api/routes/health.py` — contains the buggy `execute("SELECT 1")` call
inside `health_check()`; needs the `text` import and the query wrapped.
- `tests/unit/` — no `test_health.py` exists yet. No route-level tests
exist in this project at all (checked `tests/unit/` and confirmed no
`tests/integration/` folder either). I'll model my new test on
`test_review_service.py`'s `mock_db_session` `AsyncMock` fixture
pattern, calling `health_check()` directly as an async function rather
than through an HTTP client, consistent with how this project tests
service/route logic elsewhere.

### Plan
1. Import `text` from `sqlalchemy` at the top of `api/routes/health.py`.
2. Change `await db.execute("SELECT 1")` to
`await db.execute(text("SELECT 1"))`.
3. Manually verify via `curl http://localhost:8000/health` that the
response now reports `"postgres": "healthy"`.
4. Write `tests/unit/test_health.py`, calling `health_check()` directly
with a mocked `db` (`AsyncMock`, following `test_review_service.py`'s
fixture pattern) and asserting `dependencies["postgres"] == "healthy"`
after the fix. Mock the Redis and vector_db calls as well so the test
isolates the Postgres check specifically, rather than failing on the
unrelated #155 Redis bug.
5. Run the full test suite (`pytest` or `make test`) to confirm nothing
else breaks.

### Inputs & outputs
Input: an HTTP GET request to `/health`, with a live (or mocked) DB
session injected via `Depends(get_db)`.
Output: a JSON response with `"postgres": "healthy"` (and overall
`"status": "healthy"`, assuming other dependencies are also up) instead
of raising an error.

### Risks & unknowns
- Ran `grep -rn 'execute("' api/ core/` and confirmed no other raw-string
SQL `execute()` calls exist in the project's own code — this bug is
isolated to `health.py:31`, no sibling bugs of this type elsewhere.
- No existing `test_health.py` or route-test convention to copy exactly;
using `test_review_service.py`'s `AsyncMock` db-session fixture as the
closest available pattern. Risk: `health_check()` also calls out to
Redis and vector_db checks inline within the same function, so my test
needs to mock/isolate those too, or the test could fail on the
unrelated #155 Redis bug (`'Settings' object has no attribute
'redis_host'`) rather than testing my fix in isolation.
- The `import redis` and `from core.config import settings` calls happen
inline inside the health check function rather than at module top —
need to confirm this doesn't complicate mocking.

### Edge cases
- Postgres unreachable for a real reason (container down) — should still
correctly report `"unhealthy"` via the existing `except Exception`
block, not raise an unrelated error.
- Query succeeds but returns an unexpected result shape (unlikely for
`SELECT 1`, but worth a basic assertion in the test).
- Fix must not swallow genuine DB outage exceptions — confirm the
existing `try/except Exception` still correctly catches real failures
after the `text()` fix is applied.

**Reproduction commit link:** https://github.com/laurale31/pathreview/commit/a57447b

**PLAN.md link:** https://github.com/laurale31/pathreview/blob/fix/154-health-check-sqlalchemy-text/PLAN.md


**Blockers or open questions:**
[we'll fill this in once we check tests/integration/ below]
9 changes: 6 additions & 3 deletions api/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter, HTTPException, status, Depends
from datetime import datetime

import structlog
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text

from core.database import get_db

Expand Down Expand Up @@ -28,7 +30,7 @@ async def health_check(db=Depends(get_db)):

try:
# Check PostgreSQL
await db.execute("SELECT 1")
await db.execute(text("SELECT 1"))
health_status["dependencies"]["postgres"] = "healthy"
log.debug("postgres_health_check_passed")
except Exception as exc:
Expand All @@ -39,6 +41,7 @@ async def health_check(db=Depends(get_db)):
try:
# Check Redis (if available)
import redis

from core.config import settings

r = redis.Redis(
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Tests for api/routes/health.py"""
import pytest
from fastapi import HTTPException
from unittest.mock import AsyncMock

from api.routes.health import health_check


@pytest.mark.unit
class TestHealthCheck:
"""Test suite for the health_check route's Postgres probe."""

@pytest.fixture
def mock_db_session(self):
"""Create a mock async database session."""
session = AsyncMock()
session.execute = AsyncMock(return_value=None)
return session

@pytest.mark.asyncio
async def test_postgres_check_healthy_when_query_succeeds(self, mock_db_session):
"""Postgres check should report healthy when execute() succeeds.

Note: overall status will still be "unhealthy" and health_check()
will raise HTTPException, because the Redis check has a separate,
pre-existing bug (issue #155: settings.redis_host does not exist).
That's expected and out of scope here — we inspect the raised
exception's detail payload to confirm the Postgres check itself
is isolated from that failure.
"""
with pytest.raises(HTTPException) as exc_info:
await health_check(db=mock_db_session)

detail = exc_info.value.detail
assert detail["dependencies"]["postgres"] == "healthy"
mock_db_session.execute.assert_awaited_once()

@pytest.mark.asyncio
async def test_postgres_check_unhealthy_when_query_raises(self, mock_db_session):
"""Postgres check should report unhealthy if execute() raises."""
mock_db_session.execute.side_effect = Exception("connection failed")

with pytest.raises(HTTPException) as exc_info:
await health_check(db=mock_db_session)

detail = exc_info.value.detail
assert detail["dependencies"]["postgres"] == "unhealthy"
assert detail["status"] == "unhealthy"