From a9e1e96622dbd7da075496200090bd48563374df Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 8 Jun 2026 16:17:11 +0200 Subject: [PATCH] test: upgrade backend test suite, implement unit tests, and modernize CRUD patterns --- backend/app/crud.py | 27 ++-- backend/app/schemas.py | 6 +- backend/pyproject.toml | 1 + backend/tests/conftest.py | 21 +-- backend/tests/test_bug_repro.py | 31 +++++ backend/tests/test_config.py | 37 ++++++ backend/tests/test_crud.py | 177 ++++++++++++++++++++++++++ backend/tests/test_database.py | 29 +++++ backend/tests/test_schemas.py | 183 +++++++++++++++++++++++++++ backend/tests/test_sessions.py | 85 +++++++++++++ backend/tests/test_sessions_fixed.py | 50 ++++++++ backend/tests/test_stats.py | 82 ++++++++++++ backend/uv.lock | 55 ++++++++ 13 files changed, 759 insertions(+), 25 deletions(-) create mode 100644 backend/tests/test_config.py create mode 100644 backend/tests/test_crud.py create mode 100644 backend/tests/test_database.py create mode 100644 backend/tests/test_schemas.py diff --git a/backend/app/crud.py b/backend/app/crud.py index 313c972..be358e6 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -28,8 +28,8 @@ async def get_sessions( count_statement = count_statement.where(SleepSession.start_time >= query_from_date) if query_to_date: count_statement = count_statement.where(SleepSession.start_time <= query_to_date) - count_result = await session.exec(count_statement) - total = count_result.one() or 0 + count_result = await session.execute(count_statement) + total = count_result.scalar() or 0 statement = select(SleepSession).order_by(SleepSession.start_time.desc()) if query_from_date: @@ -38,14 +38,14 @@ async def get_sessions( statement = statement.where(SleepSession.start_time <= query_to_date) statement = statement.limit(limit) - result = await session.exec(statement) - sessions = result.all() + result = await session.execute(statement) + sessions = result.scalars().all() return list(sessions), total async def get_active_session(session: AsyncSession) -> Optional[SleepSession]: statement = select(SleepSession).where(SleepSession.end_time == None) - result = await session.exec(statement) - return result.first() + result = await session.execute(statement) + return result.scalars().first() async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession: new_session = SleepSession(start_time=start_time) @@ -58,8 +58,8 @@ async def end_sleep_session( session: AsyncSession, session_id: int, end_time: datetime ) -> Union[SleepSession, bool, str, None]: statement = select(SleepSession).where(SleepSession.id == session_id) - result = await session.exec(statement) - sleep_session = result.first() + result = await session.execute(statement) + sleep_session = result.scalars().first() if not sleep_session: return None # Not found @@ -83,8 +83,8 @@ async def end_sleep_session( async def delete_session(session: AsyncSession, session_id: int) -> bool: statement = select(SleepSession).where(SleepSession.id == session_id) - result = await session.exec(statement) - sleep_session = result.first() + result = await session.execute(statement) + sleep_session = result.scalars().first() if not sleep_session: return False @@ -97,8 +97,8 @@ async def update_session( session: AsyncSession, session_id: int, update_data: dict ) -> Optional[SleepSession]: statement = select(SleepSession).where(SleepSession.id == session_id) - result = await session.exec(statement) - sleep_session = result.first() + result = await session.execute(statement) + sleep_session = result.scalars().first() if not sleep_session: return None @@ -112,7 +112,6 @@ async def update_session( await session.refresh(sleep_session) return sleep_session - async def get_sleep_counts_by_start_date( session: AsyncSession, from_date: Optional[datetime] = None, @@ -135,5 +134,5 @@ async def get_sleep_counts_by_start_date( if query_to_date: statement = statement.where(SleepSession.start_time <= query_to_date) - result = await session.exec(statement) + result = await session.execute(statement) return list(result.all()) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 689aa6b..ded6f64 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -13,7 +13,7 @@ class SleepSessionUpdate(BaseModel): class SleepSessionRead(BaseModel): id: int start_time: datetime - end_time: Optional[datetime] + end_time: Optional[datetime] = None created_at: datetime updated_at: datetime @@ -46,8 +46,8 @@ class TimetableResponse(BaseModel): class WakeupBedtimeDay(BaseModel): date: str - wakeup_minutes: Optional[int] - bedtime_minutes: Optional[int] + wakeup_minutes: Optional[int] = None + bedtime_minutes: Optional[int] = None class WakeupBedtimeResponse(BaseModel): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6ce2b72..38eec11 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "pytest", "httpx", "pytest-asyncio", + "pytest-cov>=7.1.0", ] [tool.hatch.build.targets.wheel] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3e64b27..2a78dc3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,15 +5,16 @@ from sqlmodel import SQLModel from sqlalchemy.orm import sessionmaker from httpx import AsyncClient, ASGITransport from app.main import app +from app.database import get_session -@pytest_asyncio.fixture(scope="session") +@pytest_asyncio.fixture(scope="function") async def engine(): - """Create a single in-memory SQLite engine for the entire test session.""" + """Create a fresh in-memory SQLite engine for each test.""" engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=False, ) - # Create tables once for the whole session to avoid overhead + # Create tables for each test to ensure a clean state async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine @@ -26,10 +27,10 @@ async def session_factory(engine): @pytest_asyncio.fixture async def db_session(session_factory): - """Create a database session for a test. Rolls back after each test.""" + """Create a database session for a test.""" async with session_factory() as session: yield session - await session.rollback() + # Rollback is still good practice, though engine is fresh each time @pytest.fixture def sample_session_data(): @@ -38,10 +39,14 @@ def sample_session_data(): "start_time": "2024-01-15T21:30:00Z", } -@pytest.fixture -async def api_client(engine): - """Provides an AsyncClient for testing API endpoints.""" +@pytest_asyncio.fixture +async def api_client(db_session): + """Provides an AsyncClient for testing API endpoints with the test database session.""" + async def override(): + yield db_session + app.dependency_overrides[get_session] = override async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as client: yield client + app.dependency_overrides.clear() diff --git a/backend/tests/test_bug_repro.py b/backend/tests/test_bug_repro.py index 0c2fe3f..cb84e0d 100644 --- a/backend/tests/test_bug_repro.py +++ b/backend/tests/test_bug_repro.py @@ -1,4 +1,35 @@ import pytest +from datetime import datetime, timedelta, timezone +from app.models import SleepSession +from sqlalchemy import select + +@pytest.mark.asyncio +async def test_summary_double_counting_midnight(api_client, db_session): + # 1. Setup: Insert a sleep session that spans across midnight + start_time = datetime(2024, 1, 1, 23, 0) + end_time = datetime(2024, 1, 2, 7, 0) + + new_session = SleepSession(start_time=start_time, end_time=end_time) + db_session.add(new_session) + await db_session.commit() + + # 2. Call the stats endpoint for a period that includes this session + from_date = "2024-01-01T00:00:00" + to_date = "2024-01-02T23:59:59" + + async with api_client as ac: + response = await ac.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}") + + assert response.status_code == 200 + data = response.json() + + # 3. Verify that total_sleeps is exactly 1 for the requested period + print(f"DEBUG: data={data}") + assert data["total_sleeps"] == 1, f"Expected 1 sleep session, got {data['total_sleeps']}" + + # 4. Verify that all-time stats also count this session exactly once + assert data["total_sleeps_all_time"] == 1, f"Expected 1 all-time sleep session, got {data['total_sleeps_all_time']}" + from datetime import datetime, timedelta, timezone from app.models import SleepSession from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..a2c8f5a --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,37 @@ +import pytest +from app.config import Settings + +def test_settings_defaults(): + """Test that default settings are correctly loaded.""" + # We instantiate a new Settings object to test the class directly, + # rather than using the already-initialized 'settings' instance in app.config. + s = Settings(_env_file=None) + assert s.DATABASE_URL == "sqlite+aiosqlite:///./data/sleep.db" + assert s.CORS_ORIGINS == "http://localhost:5173" + assert s.LOG_LEVEL == "info" + +def test_settings_env_overrides(monkeypatch): + """Test that environment variables correctly override defaults.""" + # Set environment variables using monkeypatch + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://testuser:testpass@localhost/testdb") + monkeypatch.setenv("CORS_ORIGINS", "http://example.com") + monkeypatch.setenv("LOG_LEVEL", "debug") + + # Re-instantiate Settings to pick up the new environment variables + s = Settings(_env_file=None) + + assert s.DATABASE_URL == "postgresql+asyncpg://testuser:testpass@localhost/testdb" + assert s.CORS_ORIGINS == "http://example.com" + assert s.LOG_LEVEL == "debug" + +def test_settings_partial_override(monkeypatch): + """Test that overriding only some environment variables works correctly.""" + monkeypatch.setenv("LOG_LEVEL", "warning") + + s = Settings(_env_file=None) + + # Overridden value + assert s.LOG_LEVEL == "warning" + # Default values should remain + assert s.DATABASE_URL == "sqlite+aiosqlite:///./data/sleep.db" + assert s.CORS_ORIGINS == "http://localhost:5173" diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py new file mode 100644 index 0000000..1c20f1b --- /dev/null +++ b/backend/tests/test_crud.py @@ -0,0 +1,177 @@ +import pytest +from datetime import datetime, timedelta, timezone +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel import select +from app.models import SleepSession +from app.crud import ( + create_sleep_session, + get_sessions, + get_active_session, + end_sleep_session, + delete_session, + update_session, + get_sleep_counts_by_start_date, +) + +@pytest.mark.asyncio +async def test_create_sleep_session(db_session: AsyncSession): + # Use naive datetime to match DB storage behavior in tests + start_time = (datetime.now(timezone.utc) - timedelta(hours=8)).replace(tzinfo=None) + new_session = await create_sleep_session(db_session, start_time) + + assert new_session.id is not None + assert new_session.start_time == start_time + + # Verify in DB + statement = select(SleepSession).where(SleepSession.id == new_session.id) + result = await db_session.execute(statement) + db_session_record = result.scalar_one() + assert db_session_record.id == new_session.id + assert db_session_record.start_time == start_time + +@pytest.mark.asyncio +async def test_get_sessions(db_session: AsyncSession): + # Create multiple sessions (using naive datetimes) + now = datetime.now(timezone.utc).replace(tzinfo=None) + s1_start = now - timedelta(days=2) + s2_start = now - timedelta(days=1) + s3_start = now + + await create_sleep_session(db_session, s1_start) + await create_sleep_session(db_session, s2_start) + await create_sleep_session(db_session, s3_start) + + # Test all sessions + sessions, total = await get_sessions(db_session) + assert total == 3 + assert len(sessions) == 3 + # Order is descending start_time + assert sessions[0].start_time == s3_start + assert sessions[2].start_time == s1_start + + # Test filtering by from_date + sessions, total = await get_sessions(db_session, from_date=now - timedelta(days=1.5)) + assert total == 2 + assert len(sessions) == 2 + assert sessions[0].start_time == s3_start + + # Test filtering by to_date + sessions, total = await get_sessions(db_session, to_date=now - timedelta(days=1.5)) + assert total == 1 + assert len(sessions) == 1 + assert sessions[0].start_time == s1_start + +@pytest.mark.asyncio +async def test_get_active_session(db_session: AsyncSession): + now = datetime.now(timezone.utc).replace(tzinfo=None) + # Active session (no end_time) + active = await create_sleep_session(db_session, now - timedelta(hours=1)) + # Completed session + completed = await create_sleep_session(db_session, now - timedelta(hours=10)) + await end_sleep_session(db_session, completed.id, now - timedelta(hours=5)) + + retrieved_active = await get_active_session(db_session) + assert retrieved_active is not None + assert retrieved_active.id == active.id + assert retrieved_active.end_time is None + +@pytest.mark.asyncio +async def test_end_sleep_session(db_session: AsyncSession): + now = datetime.now(timezone.utc).replace(tzinfo=None) + start_time = now - timedelta(hours=8) + session = await create_sleep_session(db_session, start_time) + + # Happy path + end_time = now - timedelta(hours=1) + result = await end_sleep_session(db_session, session.id, end_time) + assert isinstance(result, SleepSession) + assert result.end_time == end_time + + # Verify in DB + statement = select(SleepSession).where(SleepSession.id == session.id) + result_db = await db_session.execute(statement) + db_record = result_db.scalar_one() + assert db_record.end_time == end_time + + # Already ended + second_end = now + timedelta(hours=1) + result_already_ended = await end_sleep_session(db_session, session.id, second_end) + assert result_already_ended is False + + # Non-existent ID + result_not_found = await end_sleep_session(db_session, 9999, now) + assert result_not_found is None + + # Invalid time (end < start) + new_session = await create_sleep_session(db_session, now) + invalid_end = now - timedelta(hours=1) + result_invalid = await end_sleep_session(db_session, new_session.id, invalid_end) + assert result_invalid == "invalid" + +@pytest.mark.asyncio +async def test_delete_session(db_session: AsyncSession): + now = datetime.now(timezone.utc).replace(tzinfo=None) + session = await create_sleep_session(db_session, now) + session_id = session.id + + # Happy path + deleted = await delete_session(db_session, session_id) + assert deleted is True + + # Verify it's gone + statement = select(SleepSession).where(SleepSession.id == session_id) + result = await db_session.execute(statement) + assert result.first() is None + + # Non-existent ID + deleted_again = await delete_session(db_session, session_id) + assert deleted_again is False + +@pytest.mark.asyncio +async def test_update_session(db_session: AsyncSession): + now = datetime.now(timezone.utc).replace(tzinfo=None) + session = await create_sleep_session(db_session, now) + + # Happy path: update end_time + new_end_time = now + timedelta(hours=2) + update_data = {"end_time": new_end_time} + updated_session = await update_session(db_session, session.id, update_data) + + assert updated_session is not None + assert updated_session.end_time == new_end_time + + # Verify in DB + statement = select(SleepSession).where(SleepSession.id == session.id) + result = await db_session.execute(statement) + db_record = result.scalar_one() + assert db_record.end_time == new_end_time + + # Non-existent ID + result_not_found = await update_session(db_session, 9999, {"start_time": now}) + assert result_not_found is None + +@pytest.mark.asyncio +async def test_get_sleep_counts_by_start_date(db_session: AsyncSession): + # Use fixed dates to be safe for testing grouping + date1 = datetime(2024, 6, 1, 22, 0) + date2 = datetime(2024, 6, 1, 23, 0) # Same day + date3 = datetime(2024, 6, 2, 1, 0) # Next day + date4 = datetime(2024, 6, 3, 1, 0) # Day after + + await create_sleep_session(db_session, date1) + await create_sleep_session(db_session, date2) + await create_sleep_session(db_session, date3) + await create_sleep_session(db_session, date4) + + # Get counts. Note: func.date returns strings in SQLite like '2024-06-01' + counts = await get_sleep_counts_by_start_date(db_session) + + # Expected: [('2024-06-03', 1), ('2024-06-02', 1), ('2024-06-01', 2)] + assert len(counts) == 3 + + # Finding counts by scanning because order/format might vary slightly depending on sqlite func.date behavior + # but it should be descending in our code (order_by(func.date(...).desc())) + assert counts[0][0] == '2024-06-03' and counts[0][1] == 1 + assert counts[1][0] == '2024-06-02' and counts[1][1] == 1 + assert counts[2][0] == '2024-06-01' and counts[2][1] == 2 + diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py new file mode 100644 index 0000000..0e7eac4 --- /dev/null +++ b/backend/tests/test_database.py @@ -0,0 +1,29 @@ +import pytest +from unittest.mock import patch +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text +from sqlmodel import SQLModel +from app.database import init_db + +@pytest.mark.asyncio +async def test_init_db_creates_tables(): + """Verifies that init_db() successfully creates all necessary tables. + + This test uses a patched in-memory SQLite engine to ensure isolation + and verify the database initialization logic. + """ + # Use an in-memory SQLite engine for testing to avoid any impact on real data + test_engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + # Patch the engine in app.database so init_db uses our test engine + with patch("app.database.engine", test_engine): + await init_db() + + # Check if the expected table "sleep_session" exists in the schema + async with test_engine.connect() as conn: + result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='sleep_session';")) + row = result.fetchone() + assert row is not None, "Table 'sleep_session' was not created by init_db()" + assert row[0] == "sleep_session" + + await test_engine.dispose() diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 0000000..3bc696e --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,183 @@ +from datetime import datetime, timedelta +from typing import List +import pytest +from pydantic import ValidationError +from app.schemas import ( + SleepSessionCreate, + SleepSessionUpdate, + SleepSessionRead, + SessionListResponse, + DailySummaryDay, + DailySummaryResponse, + TimetableSlot, + TimetableResponse, + WakeupBedtimeDay, + WakeupBedtimeResponse, + SummaryResponse, +) + +def test_sleep_session_create_valid(): + start_time = datetime.now() + data = {"start_time": start_time} + schema = SleepSessionCreate(**data) + assert schema.start_time == start_time + +def test_sleep_session_create_invalid_missing_field(): + with pytest.raises(ValidationError): + SleepSessionCreate() + +def test_sleep_session_create_invalid_type(): + with pytest.raises(ValidationError): + SleepSessionCreate(start_time="not-a-date") + +def test_sleep_session_update_valid(): + start_time = datetime.now() + data = {"start_time": start_time} + schema = SleepSessionUpdate(**data) + assert schema.start_time == start_time + assert schema.end_time is None + +def test_sleep_session_update_valid_partial(): + data = {"end_time": datetime.now()} + schema = SleepSessionUpdate(**data) + assert schema.end_time is not None + assert schema.start_time is None + +def test_sleep_session_read_valid(): + now = datetime.now() + data = { + "id": 1, + "start_time": now, + "end_time": now + timedelta(hours=8), + "created_at": now, + "updated_at": now, + } + schema = SleepSessionRead(**data) + assert schema.id == 1 + assert schema.start_time == now + assert schema.end_time == now + timedelta(hours=8) + +def test_sleep_session_read_invalid_id(): + now = datetime.now() + data = { + "id": "not-an-int", + "start_time": now, + "end_time": None, + "created_at": now, + "updated_at": now, + } + with pytest.raises(ValidationError): + SleepSessionRead(**data) + +def test_session_list_response_valid(): + now = datetime.now() + sessions = [ + { + "id": 1, + "start_time": now, + "end_time": None, + "created_at": now, + "updated_at": now, + } + ] + data = {"sessions": sessions, "total": 1} + schema = SessionListResponse(**data) + assert len(schema.sessions) == 1 + assert schema.total == 1 + +def test_session_list_response_invalid_sessions(): + with pytest.raises(ValidationError): + SessionListResponse(sessions="not-a-list", total=0) + +def test_daily_summary_day_valid(): + data = { + "date": "2023-01-01", + "total_minutes": 480, + "day_minutes": 200, + "night_minutes": 280, + } + schema = DailySummaryDay(**data) + assert schema.date == "2023-01-01" + assert schema.total_minutes == 480 + +def test_daily_summary_day_invalid_type(): + with pytest.raises(ValidationError): + DailySummaryDay(date="2023-01-01", total_minutes="not-an-int", day_minutes=100, night_minutes=100) + +def test_daily_summary_response_valid(): + days = [ + { + "date": "2023-01-01", + "total_minutes": 480, + "day_minutes": 200, + "night_minutes": 280, + } + ] + data = {"days": days} + schema = DailySummaryResponse(**data) + assert len(schema.days) == 1 + +def test_timetable_slot_valid(): + data = { + "date": "2023-01-01", + "slot_start": "08:00", + "slot_end": "09:00", + "sleeping": True, + } + schema = TimetableSlot(**data) + assert schema.sleeping is True + +def test_timetable_slot_invalid_sleeping(): + with pytest.raises(ValidationError): + TimetableSlot(date="2023-01-01", slot_start="08:00", slot_end="09:00", sleeping="not-a-bool") + +def test_timetable_response_valid(): + slots = [ + { + "date": "2023-01-01", + "slot_start": "08:00", + "slot_end": "09:00", + "sleeping": True, + } + ] + data = {"slots": slots} + schema = TimetableResponse(**data) + assert len(schema.slots) == 1 + +def test_wakeup_bedtime_day_valid(): + data = {"date": "2023-01-01", "wakeup_minutes": 480, "bedtime_minutes": 1200} + schema = WakeupBedtimeDay(**data) + assert schema.wakeup_minutes == 480 + assert schema.bedtime_minutes == 1200 + +def test_wakeup_bedtime_day_optional_fields(): + data = {"date": "2023-01-01"} + schema = WakeupBedtimeDay(**data) + assert schema.wakeup_minutes is None + assert schema.bedtime_minutes is None + +def test_wakeup_bedtime_response_valid(): + days = [{"date": "2023-01-01", "wakeup_minutes": 480, "bedtime_minutes": 1200}] + data = {"days": days} + schema = WakeupBedtimeResponse(**data) + assert len(schema.days) == 1 + +def test_summary_response_valid(): + data = { + "total_sleeps": 10, + "avg_sleeps_per_day": 1.5, + "total_sleeps_all_time": 100, + "avg_sleeps_per_day_all_time": 1.2, + } + schema = SummaryResponse(**data) + assert schema.total_sleeps == 10 + assert schema.avg_sleeps_per_day == 1.5 + +def test_summary_response_invalid_float(): + with pytest.raises(ValidationError): + SummaryResponse( + total_sleeps=10, + avg_sleeps_per_day="not-a-float", + total_sleeps_all_time=100, + avg_sleeps_per_day_all_time=1.2, + ) diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index 0ba1fa9..8e7e602 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -1,4 +1,89 @@ import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from datetime import datetime, timezone, timedelta + +@pytest.mark.asyncio +async def test_create_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + payload = {"start_time": start_time} + response = await ac.post("/api/sessions", json=payload) + assert response.status_code == 201 + data = response.json() + assert datetime.fromisoformat(data["start_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(start_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_create_session_already_open(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.post("/api/sessions", json={"start_time": start_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_end_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + + end_time = datetime.now(timezone.utc).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + + assert response.status_code == 200 + assert datetime.fromisoformat(response.json()["end_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(end_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_list_sessions(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.get("/api/sessions") + assert response.status_code == 200 + assert len(response.json()["sessions"]) >= 1 + + +@pytest.mark.asyncio +async def test_end_session_before_start_time(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + end_time = ( + datetime.now(timezone.utc).replace(hour=0, minute=0) - timedelta(hours=1) + ).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_end_already_ended_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + end_time = datetime.now(timezone.utc).isoformat() + await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_delete_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.delete("/api/sessions/1") + assert response.status_code == 204 + + response = await ac.get("/api/sessions") + assert not any(s["id"] == 1 for s in response.json()["sessions"]) + from httpx import AsyncClient, ASGITransport from app.main import app from app.database import engine, init_db diff --git a/backend/tests/test_sessions_fixed.py b/backend/tests/test_sessions_fixed.py index 17fa040..83abff3 100644 --- a/backend/tests/test_sessions_fixed.py +++ b/backend/tests/test_sessions_fixed.py @@ -1,4 +1,54 @@ import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from datetime import datetime, timezone + +@pytest.mark.asyncio +async def test_create_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + payload = {"start_time": start_time} + response = await ac.post("/api/sessions", json=payload) + assert response.status_code == 201 + data = response.json() + assert datetime.fromisoformat(data["start_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(start_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_create_session_already_open(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.post("/api/sessions", json={"start_time": start_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_end_session(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + + end_time = datetime.now(timezone.utc).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + + assert response.status_code == 200 + assert datetime.fromisoformat(response.json()["end_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(end_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_list_sessions(api_client): + async with api_client as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.get("/api/sessions") + assert response.status_code == 200 + assert len(response.json()["sessions"]) >= 1 + from httpx import AsyncClient, ASGITransport from app.main import app from app.database import engine, init_db diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py index eea2cb7..e6de98f 100644 --- a/backend/tests/test_stats.py +++ b/backend/tests/test_stats.py @@ -1,4 +1,86 @@ import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from datetime import datetime, timezone, timedelta, date +from app.models import SleepSession + +@pytest.mark.asyncio +async def test_daily_summary(api_client, db_session): + from sqlalchemy import select + + # Create a session spanning today + start = datetime.now(timezone.utc) - timedelta(hours=5) + end = datetime.now(timezone.utc) + + # Use the db_session fixture for insertion + new_s = SleepSession(start_time=start, end_time=end) + db_session.add(new_s) + await db_session.commit() + + from_date = date.today().isoformat() + to_date = (date.today() + timedelta(days=1)).isoformat() + + async with api_client as ac: + response = await ac.get( + f"/api/stats/daily-summary?from_date={from_date}&to_date={to_date}" + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) >= 1 + + +@pytest.mark.asyncio +async def test_timetable(api_client, db_session): + from datetime import date + + start = datetime.now(timezone.utc) - timedelta(hours=1) + new_s = SleepSession(start_time=start, end_time=datetime.now(timezone.utc)) + db_session.add(new_s) + await db_session.commit() + + from_date = date.today().isoformat() + to_date = (date.today() + timedelta(days=1)).isoformat() + + async with api_client as ac: + response = await ac.get( + f"/api/stats/timetable?from_date={from_date}&to_date={to_date}" + ) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_summary(api_client, db_session): + from datetime import date, timedelta, timezone + + # Create two sessions, one a few days ago and one today + start1 = datetime.now(timezone.utc) - timedelta(days=5) + end1 = start1 + timedelta(hours=8) + new_s1 = SleepSession(start_time=start1, end_time=end1) + db_session.add(new_s1) + + start2 = datetime.now(timezone.utc) - timedelta(hours=2) + end2 = datetime.now(timezone.utc) + new_s2 = SleepSession(start_time=start2, end_time=end2) + db_session.add(new_s2) + + await db_session.commit() + + from_date = (date.today() - timedelta(days=7)).isoformat() + to_date = date.today().isoformat() + + async with api_client as ac: + response = await ac.get( + f"/api/stats/summary?from_date={from_date}&to_date={to_date}" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total_sleeps"] == 2 + assert data["total_sleeps_all_time"] == 2 + assert data["avg_sleeps_per_day_all_time"] > 0 + from httpx import AsyncClient, ASGITransport from app.main import app from app.database import engine, init_db diff --git a/backend/uv.lock b/backend/uv.lock index 44064c5..cd88796 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -62,6 +62,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -82,6 +83,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, ] [[package]] @@ -114,6 +116,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + [[package]] name = "fastapi" version = "0.135.3" @@ -337,6 +378,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2"