ginchiee-bot / tests /test_database.py
Catalna's picture
ο»Ώfix: await the timezone lookup, and copy Postgres data across environments
7d3a67d
Raw History Blame Contribute Delete
19.3 kB
"""
tests/test_database.py
Unit tests for the placeholder translator, plus an integration suite that runs
against a real PostgreSQL database when DATABASE_URL is available.
The integration tests are the point: they prove the wrapper keeps the aiosqlite
call patterns working (`await db.execute(...)`, `async with db.execute(...) as
cursor`), that `?` never breaks inside string literals, that migrations are
idempotent, and that transactional writes roll back.
Run:
python -m unittest tests.test_database -v
"""
import asyncio
import os
import unittest
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from database.connection import translate_placeholders # noqa: E402
PROBE_USER_ID = 999_999_001
class PlaceholderTranslationTests(unittest.TestCase):
"""Pure functions β€” no database required."""
def test_single_placeholder(self) -> None:
self.assertEqual(
translate_placeholders("SELECT * FROM users WHERE user_id = ?"),
"SELECT * FROM users WHERE user_id = $1",
)
def test_multiple_placeholders_number_in_order(self) -> None:
self.assertEqual(
translate_placeholders("UPDATE t SET a = ?, b = ? WHERE id = ?"),
"UPDATE t SET a = $1, b = $2 WHERE id = $3",
)
def test_question_mark_inside_string_literal_is_preserved(self) -> None:
sql = "INSERT INTO t (q) VALUES ('sudah makan?')"
result = translate_placeholders(sql)
self.assertEqual(result, "INSERT INTO t (q) VALUES ('sudah makan?')")
self.assertNotIn("$1", result)
def test_mixed_placeholder_and_literal(self) -> None:
sql = "SELECT * FROM t WHERE name = ? AND note = 'why?'"
self.assertEqual(
translate_placeholders(sql),
"SELECT * FROM t WHERE name = $1 AND note = 'why?'",
)
def test_escaped_quote_in_literal(self) -> None:
sql = "INSERT INTO t (v) VALUES ('it''s ok?')"
self.assertEqual(
translate_placeholders(sql),
"INSERT INTO t (v) VALUES ('it''s ok?')",
)
def test_no_placeholder(self) -> None:
sql = "SELECT now()"
self.assertEqual(translate_placeholders(sql), sql)
def _resolve_database_url() -> str | None:
"""Read DATABASE_URL from the environment or the project .env file."""
if os.getenv("DATABASE_URL"):
return os.getenv("DATABASE_URL")
env_path = PROJECT_ROOT / ".env"
if not env_path.exists():
return None
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("DATABASE_URL="):
value = line.split("=", 1)[1].strip()
return value or None
return None
class _FrozenDatetime:
"""Stand-in for `datetime` whose `now(tz)` returns a fixed instant.
Used to pin the clock inside timezone assertions without touching the
system clock (which would be flaky and affect other tests).
"""
def __init__(self, moment):
self._moment = moment
def now(self, tz=None):
if tz is None:
return self._moment.replace(tzinfo=None)
return self._moment.astimezone(tz)
def date(self):
return self._moment.date()
@unittest.skipUnless(_resolve_database_url(), "DATABASE_URL not available")
class PostgresIntegrationTests(unittest.TestCase):
"""All checks run inside one event loop (see module docstring)."""
def setUp(self) -> None:
from database.connection import close_db
# A previous run may have left the pool bound to a dead loop; closing it
# here is safe because the pool is torn down in the same loop that will
# recreate it, at the end of _run_all_checks.
self._close_db = close_db
asyncio.run(self._close_pool_if_possible())
async def _close_pool_if_possible(self) -> None:
import database.connection as connection_module
if connection_module._pool is not None:
try:
await connection_module._pool.close()
except Exception:
pass
connection_module._pool = None
# ── Individual checks ────────────────────────────────────────────────────
async def _reset_probe(self, db) -> None:
"""Remove any probe rows so each check starts from a clean slate."""
await db.execute(
"DELETE FROM user_activities WHERE user_id = $1", (PROBE_USER_ID,)
)
await db.execute("DELETE FROM users WHERE user_id = $1", (PROBE_USER_ID,))
async def _check_migrations_idempotent(self, db) -> None:
from database.migrations import run_migrations
await run_migrations()
second = await run_migrations()
self.assertEqual(second, [], f"re-running migrations applied {second}")
recorded = await db.execute(
"SELECT version, name FROM schema_migrations ORDER BY version"
).fetchall()
self.assertTrue(recorded, "no migrations recorded")
self.assertEqual(recorded[0]["version"], 1)
async def _check_tables(self, db) -> None:
rows = await db.execute(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
"""
).fetchall()
names = {row[0] for row in rows}
expected = {
"users", "diet_profiles", "meal_schedules", "food_logs",
"conversations", "user_activities", "schema_migrations",
}
self.assertTrue(expected.issubset(names), f"missing tables: {sorted(expected - names)}")
# google_calendar_tokens: orphaned, no code references it.
# daily_nutrition: write-only, never read.
self.assertNotIn("google_calendar_tokens", names)
self.assertNotIn("daily_nutrition", names)
async def _check_await_execute(self, db) -> None:
await db.execute(
"""
INSERT INTO users (user_id, name, username, timezone)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET name = excluded.name
""",
(PROBE_USER_ID, "Test User", "tester", "Asia/Jakarta"),
)
await db.commit()
value = await db.execute(
"SELECT name FROM users WHERE user_id = $1", (PROBE_USER_ID,)
).fetchval()
self.assertEqual(value, "Test User")
async def _check_async_with_cursor(self, db) -> None:
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Cursor Test", "cur"),
)
async with db.execute(
"SELECT user_id, name, timezone FROM users WHERE user_id = $1",
(PROBE_USER_ID,),
) as cursor:
row = await cursor.fetchone()
self.assertIsNotNone(row)
self.assertEqual(dict(row)["name"], "Cursor Test")
self.assertEqual(row[0], PROBE_USER_ID)
async def _check_returning_id(self, db) -> None:
# Foreign keys are enforced, so the parent user must exist first.
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Returning Test", "ret"),
)
activity_id = await db.execute(
"""
INSERT INTO user_activities (user_id, title, activity_dt, remind_mins)
VALUES ($1, $2, now() + interval '1 day', 30)
RETURNING id
""",
(PROBE_USER_ID, "Test Activity"),
).fetchval()
self.assertIsInstance(activity_id, int)
deleted = await db.execute(
"DELETE FROM user_activities WHERE id = $1 AND user_id = $2 RETURNING id",
(activity_id, PROBE_USER_ID),
).fetchone()
self.assertIsNotNone(deleted)
async def _check_meal_type_inference(self, db) -> None:
"""meal_type used to be permanently NULL, making /today emoji useless."""
from services.nutrition_service import get_today_food_logs, user_today
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Meal Type Test", "mt"),
)
logged = await db.execute(
"""
INSERT INTO food_logs (user_id, date, meal_type, food_name, amount_g,
calories, protein_g, carbs_g, fat_g)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
""",
(PROBE_USER_ID, await user_today(PROBE_USER_ID), "lunch", "nasi goreng",
200.0, 320.0, 8.0, 45.0, 10.0),
).fetchval()
self.assertIsInstance(logged, int)
rows = await get_today_food_logs(PROBE_USER_ID)
self.assertTrue(rows)
self.assertEqual(rows[-1]["meal_type"], "lunch")
async def _check_today_follows_user_timezone(self, db) -> None:
"""Regression test for a real data-correctness bug.
date.today() read the machine's timezone. On Hugging Face the
container runs in UTC while users are in Asia/Jakarta (UTC+7), so
between 00:00 and 07:00 WIB the UTC date is still the previous day.
Meals logged then got filed under yesterday and /today showed nothing.
The previous version of this fix called the async `_tz_for()` without
awaiting it, so the lookup silently failed and user_today() fell back
to the machine timezone β€” meaning the test passed through the fallback.
This test therefore proves the timezone is genuinely read from
`users.timezone` by setting an UNUSUAL zone and expecting the date to
follow it, which only happens if the query really runs.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from services.nutrition_service import user_today
from services.user_service import (
create_or_update_user,
update_user_timezone,
)
probe = PROBE_USER_ID + 41
# WIT is UTC+9 and WIB is UTC+7, so only between 22:00 and 23:59 WIB do
# the two zones fall on different calendar days.
wib = ZoneInfo("Asia/Jakarta")
wit = ZoneInfo("Asia/Jayapura")
sample = datetime(2026, 10, 12, 23, 0, tzinfo=wib) # 23:00 WIB
expected_with_jakarta = sample.astimezone(wib).date()
expected_with_jayapura = sample.astimezone(wit).date()
if expected_with_jayapura == expected_with_jakarta:
self.skipTest("sample date lands on the same day in both zones")
await db.execute("DELETE FROM users WHERE user_id=$1", (probe,))
await create_or_update_user(probe, "TZ Probe", None, "Asia/Jakarta")
try:
import services.nutrition_service as ns
original = ns.datetime
ns.datetime = _FrozenDatetime(sample)
try:
# With the user on WIB.
await update_user_timezone(probe, "Asia/Jakarta")
as_jakarta = await user_today(probe)
# With the user on WIT (UTC+9) the SAME instant is a later date,
# so only a real timezone lookup can produce this.
await update_user_timezone(probe, "Asia/Jayapura")
as_jayapura = await user_today(probe)
finally:
ns.datetime = original
self.assertEqual(
as_jakarta, expected_with_jakarta,
"user_today must honour Asia/Jakarta",
)
self.assertEqual(
as_jayapura, expected_with_jayapura,
"user_today must change when the stored timezone changes "
"(a fallback would keep returning the machine's date)",
)
self.assertNotEqual(
as_jayapura, as_jakarta,
"the two zones must actually differ for this test to mean anything",
)
finally:
await db.execute("DELETE FROM users WHERE user_id=$1", (probe,))
async def _check_activity_due_reminder(self, db) -> None:
from datetime import datetime, timedelta, timezone
from services.activity_service import (
add_activity,
get_due_activity_reminders,
delete_activity,
)
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Reminder Test", "rem"),
)
# 10 minutes away with a 30 minute lead time -> already due.
soon = datetime.now(timezone.utc) + timedelta(minutes=10)
# 3 days away with a 30 minute lead time -> not due.
later = datetime.now(timezone.utc) + timedelta(days=3)
soon_id = await add_activity(
PROBE_USER_ID, "Reminder Soon", soon.isoformat(timespec="seconds"), remind_mins=30
)
later_id = await add_activity(
PROBE_USER_ID, "Reminder Later", later.isoformat(timespec="seconds"), remind_mins=30
)
due = await get_due_activity_reminders()
due_ids = {row["id"] for row in due}
dump = await db.execute(
"""
SELECT id, title, activity_dt, remind_mins, reminded, user_id
FROM user_activities WHERE user_id = $1
""",
(PROBE_USER_ID,),
).fetchall()
now_val = await db.execute("SELECT now()").fetchval()
print(f" [diag] now={now_val}")
for r in dump:
due_at = r[2].timestamp() and (r[2] - __import__("datetime").timedelta(minutes=r[3]))
print(f" [diag] row id={r[0]} title={r[1]!r} dt={r[2]} remind={r[3]} due_at={due_at} in_due={r[0] in due_ids}")
print(f" [diag] due_ids={sorted(due_ids)} soon={soon_id} later={later_id}")
self.assertIn(soon_id, due_ids, "reminder within the lead window should be due")
self.assertNotIn(later_id, due_ids, "activity 3 days out should not be due")
await delete_activity(soon_id, PROBE_USER_ID)
await delete_activity(later_id, PROBE_USER_ID)
async def _check_remind_mins_is_bounded(self, db) -> None:
"""An absurd lead time must not make far-future activities fire now."""
from datetime import datetime, timedelta, timezone
from services.activity_service import (
add_activity,
get_due_activity_reminders,
delete_activity,
)
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Bounds Test", "bnd"),
)
far = datetime.now(timezone.utc) + timedelta(days=3)
activity_id = await add_activity(
PROBE_USER_ID, "Far Future", far.isoformat(timespec="seconds"),
remind_mins=999_999_999,
)
try:
due_ids = {row["id"] for row in await get_due_activity_reminders()}
self.assertNotIn(
activity_id, due_ids,
"an absurd remind_mins must not fire a far-future activity",
)
finally:
await delete_activity(activity_id, PROBE_USER_ID)
async def _check_question_mark_translation(self, db) -> None:
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES (?, ?, ?)",
(PROBE_USER_ID, "Question Mark", "qm"),
)
async with db.execute(
"SELECT name FROM users WHERE user_id = ?", (PROBE_USER_ID,)
) as cursor:
row = await cursor.fetchone()
self.assertEqual(row[0], "Question Mark")
async def _check_literal_question_mark(self, db) -> None:
await db.execute(
"INSERT INTO users (user_id, name, username) VALUES (?, ?, ?)",
(PROBE_USER_ID, "sudah makan?", "qmlit"),
)
value = await db.execute(
"SELECT name FROM users WHERE user_id = ?", (PROBE_USER_ID,)
).fetchval()
self.assertEqual(value, "sudah makan?")
async def _check_transaction_rollback(self, db) -> None:
async def _fail(tx) -> None:
await tx.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Tx Rollback", "txrb"),
)
# Second insert violates the PRIMARY KEY on user_id.
await tx.execute(
"INSERT INTO users (user_id, name, username) VALUES ($1, $2, $3)",
(PROBE_USER_ID, "Duplicate", "txdup"),
)
with self.assertRaises(Exception):
async with db.transaction() as tx:
await _fail(tx)
exists = await db.execute(
"SELECT 1 FROM users WHERE user_id = $1", (PROBE_USER_ID,)
).fetchone()
self.assertIsNone(exists, "transaction was not rolled back")
# ── Suite ────────────────────────────────────────────────────────────────
def test_all_checks_against_real_database(self) -> None:
asyncio.run(self._run_all_checks())
async def _run_all_checks(self) -> None:
from database.connection import init_db
db = await init_db()
checks = [
("migrations idempotent", self._check_migrations_idempotent),
("expected tables exist", self._check_tables),
("await db.execute pattern", self._check_await_execute),
("async with cursor pattern", self._check_async_with_cursor),
("RETURNING id + fetchval", self._check_returning_id),
("meal_type is populated", self._check_meal_type_inference),
("today follows user timezone", self._check_today_follows_user_timezone),
("? placeholder translation", self._check_question_mark_translation),
("literal ? preserved", self._check_literal_question_mark),
("transaction rollback", self._check_transaction_rollback),
("timestamptz due reminders", self._check_activity_due_reminder),
("remind_mins bounded", self._check_remind_mins_is_bounded),
]
failures = []
for name, check in checks:
await self._reset_probe(db)
try:
await check(db)
print(f" PASS {name}")
except Exception as exc: # noqa: BLE001
print(f" FAIL {name}: {exc}")
failures.append((name, exc))
# Always clean the probe user so the next run starts clean.
await self._reset_probe(db)
await self._reset_probe(db)
# Close in the same event loop that created the pool.
from database.connection import close_db
await close_db()
if failures:
self.fail(f"{len(failures)} check(s) failed: {[n for n, _ in failures]}")
if __name__ == "__main__":
unittest.main()