8 Commits

Author SHA1 Message Date
jacobh2 a9e1e96622 test: upgrade backend test suite, implement unit tests, and modernize CRUD patterns
Build Backend / build (push) Successful in 6m39s
2026-06-08 16:17:11 +02:00
jacobh2 4d53887fcc Update todo
Build Frontend / build (push) Successful in 8m35s
2026-06-07 22:46:36 +02:00
jacobh2 673c151213 Update clean tool 2026-06-07 22:46:01 +02:00
jacobh2 8b4f519e55 Adding some test 2026-06-07 22:42:09 +02:00
jacobh2 f14e58d42c chore(vite): prioritise .tsx/.ts over .js in resolve.extensions
Prevents stale compiled .js files from shadowing their .tsx sources
when the AI agent builds or type-checks the frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:41:38 +02:00
jacobh2 a86bf2ded4 feat(frontend): remove 'v' prefix from footer and add dev mode versioning
Build Backend / build (push) Successful in 2m42s
Build Frontend / build (push) Successful in 11m0s
2026-06-07 20:31:30 +02:00
jacobh2 969b8e033f No idea what it is doing... very sad 2026-06-07 19:59:31 +02:00
jacobh2 cfc3156906 refactor: update sleep session logic, models, and agent protocols 2026-06-04 16:37:37 +02:00
24 changed files with 948 additions and 54 deletions
+6
View File
@@ -56,6 +56,12 @@ Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorep
- **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code. - **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
- **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it. - **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
## MANDATORY EXECUTION PROTOCOLS (INJECTABLE)
The following protocols must be strictly adhered to upon injection:
- **Read Before Edit**: ALWAYS read the entire target file before performing any `edit` operation.
- **Post-Edit Verification**: Immediately after an edit, verify that no lines have been duplicated.
- **Stability Check**: Following any code change, run both backend and frontend services AND the full test suite to ensure system stability.
## COMMANDS ## COMMANDS
### Backend Development ### Backend Development
+13 -14
View File
@@ -28,8 +28,8 @@ async def get_sessions(
count_statement = count_statement.where(SleepSession.start_time >= query_from_date) count_statement = count_statement.where(SleepSession.start_time >= query_from_date)
if query_to_date: if query_to_date:
count_statement = count_statement.where(SleepSession.start_time <= query_to_date) count_statement = count_statement.where(SleepSession.start_time <= query_to_date)
count_result = await session.exec(count_statement) count_result = await session.execute(count_statement)
total = count_result.one() or 0 total = count_result.scalar() or 0
statement = select(SleepSession).order_by(SleepSession.start_time.desc()) statement = select(SleepSession).order_by(SleepSession.start_time.desc())
if query_from_date: if query_from_date:
@@ -38,14 +38,14 @@ async def get_sessions(
statement = statement.where(SleepSession.start_time <= query_to_date) statement = statement.where(SleepSession.start_time <= query_to_date)
statement = statement.limit(limit) statement = statement.limit(limit)
result = await session.exec(statement) result = await session.execute(statement)
sessions = result.all() sessions = result.scalars().all()
return list(sessions), total return list(sessions), total
async def get_active_session(session: AsyncSession) -> Optional[SleepSession]: async def get_active_session(session: AsyncSession) -> Optional[SleepSession]:
statement = select(SleepSession).where(SleepSession.end_time == None) statement = select(SleepSession).where(SleepSession.end_time == None)
result = await session.exec(statement) result = await session.execute(statement)
return result.first() return result.scalars().first()
async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession: async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession:
new_session = SleepSession(start_time=start_time) new_session = SleepSession(start_time=start_time)
@@ -58,8 +58,8 @@ async def end_sleep_session(
session: AsyncSession, session_id: int, end_time: datetime session: AsyncSession, session_id: int, end_time: datetime
) -> Union[SleepSession, bool, str, None]: ) -> Union[SleepSession, bool, str, None]:
statement = select(SleepSession).where(SleepSession.id == session_id) statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement) result = await session.execute(statement)
sleep_session = result.first() sleep_session = result.scalars().first()
if not sleep_session: if not sleep_session:
return None # Not found return None # Not found
@@ -83,8 +83,8 @@ async def end_sleep_session(
async def delete_session(session: AsyncSession, session_id: int) -> bool: async def delete_session(session: AsyncSession, session_id: int) -> bool:
statement = select(SleepSession).where(SleepSession.id == session_id) statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement) result = await session.execute(statement)
sleep_session = result.first() sleep_session = result.scalars().first()
if not sleep_session: if not sleep_session:
return False return False
@@ -97,8 +97,8 @@ async def update_session(
session: AsyncSession, session_id: int, update_data: dict session: AsyncSession, session_id: int, update_data: dict
) -> Optional[SleepSession]: ) -> Optional[SleepSession]:
statement = select(SleepSession).where(SleepSession.id == session_id) statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement) result = await session.execute(statement)
sleep_session = result.first() sleep_session = result.scalars().first()
if not sleep_session: if not sleep_session:
return None return None
@@ -112,7 +112,6 @@ async def update_session(
await session.refresh(sleep_session) await session.refresh(sleep_session)
return sleep_session return sleep_session
async def get_sleep_counts_by_start_date( async def get_sleep_counts_by_start_date(
session: AsyncSession, session: AsyncSession,
from_date: Optional[datetime] = None, from_date: Optional[datetime] = None,
@@ -135,5 +134,5 @@ async def get_sleep_counts_by_start_date(
if query_to_date: if query_to_date:
statement = statement.where(SleepSession.start_time <= 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()) return list(result.all())
-1
View File
@@ -9,6 +9,5 @@ class SleepSession(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
start_time: datetime = Field(nullable=False) start_time: datetime = Field(nullable=False)
end_time: Optional[datetime] = Field(default=None, nullable=True) end_time: Optional[datetime] = Field(default=None, nullable=True)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+3 -8
View File
@@ -6,22 +6,17 @@ from pydantic import BaseModel
class SleepSessionCreate(BaseModel): class SleepSessionCreate(BaseModel):
start_time: datetime start_time: datetime
class SleepSessionUpdate(BaseModel): class SleepSessionUpdate(BaseModel):
start_time: Optional[datetime] = None start_time: Optional[datetime] = None
end_time: Optional[datetime] = None end_time: Optional[datetime] = None
notes: Optional[str] = None
class SleepSessionRead(BaseModel): class SleepSessionRead(BaseModel):
id: int id: int
start_time: datetime start_time: datetime
end_time: Optional[datetime] end_time: Optional[datetime] = None
notes: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class SessionListResponse(BaseModel): class SessionListResponse(BaseModel):
sessions: List[SleepSessionRead] sessions: List[SleepSessionRead]
total: int total: int
@@ -51,8 +46,8 @@ class TimetableResponse(BaseModel):
class WakeupBedtimeDay(BaseModel): class WakeupBedtimeDay(BaseModel):
date: str date: str
wakeup_minutes: Optional[int] wakeup_minutes: Optional[int] = None
bedtime_minutes: Optional[int] bedtime_minutes: Optional[int] = None
class WakeupBedtimeResponse(BaseModel): class WakeupBedtimeResponse(BaseModel):
+1
View File
@@ -21,6 +21,7 @@ dev = [
"pytest", "pytest",
"httpx", "httpx",
"pytest-asyncio", "pytest-asyncio",
"pytest-cov>=7.1.0",
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
+13 -8
View File
@@ -5,15 +5,16 @@ from sqlmodel import SQLModel
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from app.main import app from app.main import app
from app.database import get_session
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="function")
async def engine(): 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( engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", "sqlite+aiosqlite:///:memory:",
echo=False, 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: async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all) await conn.run_sync(SQLModel.metadata.create_all)
yield engine yield engine
@@ -26,10 +27,10 @@ async def session_factory(engine):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_session(session_factory): 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: async with session_factory() as session:
yield session yield session
await session.rollback() # Rollback is still good practice, though engine is fresh each time
@pytest.fixture @pytest.fixture
def sample_session_data(): def sample_session_data():
@@ -38,10 +39,14 @@ def sample_session_data():
"start_time": "2024-01-15T21:30:00Z", "start_time": "2024-01-15T21:30:00Z",
} }
@pytest.fixture @pytest_asyncio.fixture
async def api_client(engine): async def api_client(db_session):
"""Provides an AsyncClient for testing API endpoints.""" """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( async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test" transport=ASGITransport(app=app), base_url="http://test"
) as client: ) as client:
yield client yield client
app.dependency_overrides.clear()
+31
View File
@@ -1,4 +1,35 @@
import pytest 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 datetime import datetime, timedelta, timezone
from app.models import SleepSession from app.models import SleepSession
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
+37
View File
@@ -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"
+177
View File
@@ -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
+29
View File
@@ -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()
+183
View File
@@ -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,
)
+85
View File
@@ -1,4 +1,89 @@
import pytest 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 httpx import AsyncClient, ASGITransport
from app.main import app from app.main import app
from app.database import engine, init_db from app.database import engine, init_db
+50
View File
@@ -1,4 +1,54 @@
import pytest 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 httpx import AsyncClient, ASGITransport
from app.main import app from app.main import app
from app.database import engine, init_db from app.database import engine, init_db
+82
View File
@@ -1,4 +1,86 @@
import pytest 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 httpx import AsyncClient, ASGITransport
from app.main import app from app.main import app
from app.database import engine, init_db from app.database import engine, init_db
+55
View File
@@ -62,6 +62,7 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" },
] ]
[package.metadata] [package.metadata]
@@ -82,6 +83,7 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.3" 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" }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
+1 -1
View File
@@ -10,7 +10,7 @@
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color", "test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
"test:all": "vitest run && npx playwright test", "test:all": "vitest run && npx playwright test",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"clean": "find src -name '*.js' -o -name '*.jsx' -delete" "clean": "find .. -not -path '*/node_modules/*' -not -name 'eslint.config.js' \\( -name '*.js' -o -name '*.jsx' \\) -delete"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
+1 -1
View File
@@ -4,7 +4,7 @@ const Footer: React.FC = () => {
return ( return (
<footer className="py-4 text-center"> <footer className="py-4 text-center">
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
v{__APP_VERSION__} ({__APP_GITSHA__}) {__APP_VERSION__} ({__APP_GITSHA__})
</p> </p>
</footer> </footer>
); );
+11 -11
View File
@@ -4,7 +4,7 @@ import { useSettings } from '../../hooks/useSettings';
import { Pagination } from './Pagination'; import { Pagination } from './Pagination';
import { Edit2, Check, X, Trash2 } from 'lucide-react'; import { Edit2, Check, X, Trash2 } from 'lucide-react';
import type { SleepSessionRead } from '../../types'; import type { SleepSessionRead } from '../../types';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time'; import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes, toUTCFromLocalInput, parseBackendDateTime } from '../../utils/time';
export function SessionList() { export function SessionList() {
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions(); const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
@@ -29,13 +29,13 @@ export function SessionList() {
const handleSave = async (id: number) => { const handleSave = async (id: number) => {
try { try {
await updateSession({ await updateSession({
sessionId: id, sessionId: id,
payload: { payload: {
start_time: new Date(editStart).toISOString(), start_time: toUTCFromLocalInput(editStart),
end_time: editEnd ? new Date(editEnd).toISOString() : undefined, end_time: editEnd ? toUTCFromLocalInput(editEnd) : undefined,
} },
}); });
setEditingId(null); setEditingId(null);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -57,8 +57,8 @@ export function SessionList() {
const startEditing = (s: SleepSessionRead) => { const startEditing = (s: SleepSessionRead) => {
setEditingId(s.id); setEditingId(s.id);
setEditStart(toLocalDatetimeInputValue(new Date(s.start_time))); setEditStart(toLocalDatetimeInputValue(parseBackendDateTime(s.start_time)));
setEditEnd(s.end_time ? toLocalDatetimeInputValue(new Date(s.end_time)) : ''); setEditEnd(s.end_time ? toLocalDatetimeInputValue(parseBackendDateTime(s.end_time)) : '');
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -161,6 +161,6 @@ export function SessionList() {
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
/> />
)} )}
</div> </div >
); );
} }
+21 -3
View File
@@ -38,9 +38,15 @@ export function adjustForTimezone(utcMinutes: number): number {
* @returns string in "YYYY-MM-DDTHH:MM" format * @returns string in "YYYY-MM-DDTHH:MM" format
*/ */
export function toLocalDatetimeInputValue(date: Date): string { export function toLocalDatetimeInputValue(date: Date): string {
const offset = date.getTimezoneOffset() * 60 * 1000; const formatter = new Intl.DateTimeFormat('sv-SE', {
const localDate = new Date(date.getTime() - offset); year: 'numeric',
return localDate.toISOString().slice(0, 16); month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return formatter.format(date).replace(' ', 'T');
} }
export function formatElapsedTime(utcTimestamp: string): string { export function formatElapsedTime(utcTimestamp: string): string {
@@ -192,3 +198,15 @@ export function formatMinutes(totalMinutes: number): string {
} }
return `${mins}m`; return `${mins}m`;
} }
/**
* Parses a backend timestamp string into a Date object,
* ensuring it is treated as UTC if no timezone info is present.
*/
export function parseBackendDateTime(time: string): Date {
if (typeof time === 'string' && !time.endsWith('Z') && !time.includes('+')) {
return new Date(time + 'Z');
}
// If it already has Z or offset, new Date() will handle it correctly.
return new Date(time);
}
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SessionList } from '../../../../src/components/stats/SessionList';
vi.mock('../../../../src/hooks/useSleepSession', () => ({
useSessions: () => ({
sessions: [
{
id: 1,
start_time: '2026-06-04T11:58:00',
end_time: '2026-06-04T13:58:00',
notes: null,
created_at: '2026-06-04T11:58:00',
updated_at: '2026-06-04T11:58:00',
},
],
isLoading: false,
updateSession: vi.fn(),
deleteSession: vi.fn(),
isDeleting: false,
}),
}));
vi.mock('../../../../src/hooks/useSettings', () => ({
useSettings: () => ({ settings: { sessionsPerPage: 10 } }),
}));
const originalTZ = process.env.TZ;
beforeAll(() => {
process.env.TZ = 'Europe/Stockholm';
});
afterAll(() => {
process.env.TZ = originalTZ;
});
describe('SessionList edit timezone', () => {
it('pre-fills edit inputs with local Stockholm time, not UTC', () => {
const { container } = render(<SessionList />);
const clickableRow = container.querySelector('.cursor-pointer') as HTMLElement;
expect(clickableRow).not.toBeNull();
fireEvent.click(clickableRow);
const inputs = container.querySelectorAll('input[type="datetime-local"]');
expect(inputs).toHaveLength(2);
// Backend sends 11:58 UTC (no Z). Stockholm is UTC+2, so local time is 13:58.
expect((inputs[0] as HTMLInputElement).value).toBe('2026-06-04T13:58');
// Backend sends 13:58 UTC → Stockholm 15:58
expect((inputs[1] as HTMLInputElement).value).toBe('2026-06-04T15:58');
});
});
+26
View File
@@ -13,6 +13,7 @@ import {
getLocalOffsetFromReference, getLocalOffsetFromReference,
formatDateTime, formatDateTime,
toUTCFromLocalInput, toUTCFromLocalInput,
parseBackendDateTime,
} from '../../src/utils/time'; } from '../../src/utils/time';
describe('formatMinutesAfter', () => { describe('formatMinutesAfter', () => {
@@ -167,4 +168,29 @@ describe('toUTCFromLocalInput', () => {
expect(toUTCFromLocalInput('2026-06-0')).toBe(''); expect(toUTCFromLocalInput('2026-06-0')).toBe('');
}); });
}); });
describe('parseBackendDateTime', () => {
it('should parse a UTC string without Z as UTC', () => {
const time = '2026-06-04 13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a UTC string with Z as UTC', () => {
const time = '2026-06-04T13:58:00Z';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
it('should parse a string with an offset correctly', () => {
const time = '2026-06-04T13:58:00+02:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T11:58:00.000Z');
});
it('should handle ISO format with T', () => {
const time = '2026-06-04T13:58:00';
const date = parseBackendDateTime(time);
expect(date.toISOString()).toBe('2026-06-04T13:58:00.000Z');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { toLocalDatetimeInputValue } from '../../src/utils/time';
describe('toLocalDatetimeInputValue - Timezone Correctness', () => {
it('converts a UTC date to the correct local datetime-local input value (Stockholm)', () => {
// 11:58 UTC on June 4th, 2026 should be 13:58 in Stockholm (UTC+2)
const date = new Date('2026-06-04T11:58:00Z');
// We calculate the expected value using a deterministic formatter with explicit timezone
const expected = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Europe/Stockholm'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// This assertion will fail if the function returns UTC instead of Stockholm local time
expect(result).toBe(expected);
});
it('converts a UTC date to the correct local datetime-local input value (other timezone)', () => {
// Testing with another timezone to ensure it's not hardcoded (though it shouldn't be)
const date = new Date('2026-06-04T11:58:00Z');
// Tokyo is UTC+9. 11:58 UTC -> 20:58 Tokyo
const expectedTokyo = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Tokyo'
}).format(date).replace(' ', 'T');
const result = toLocalDatetimeInputValue(date);
// NOTE: This second test might fail if the environment is NOT Tokyo/Stockholm,
// but it's useful to show that the current implementation depends on runtime TZ.
// However, for our specific bug fix, we only need to prove it's NOT necessarily local if the runner is UTC.
// Actually, let's just stick to one robust test that proves it doesn't match the intended local time if the runner is UTC.
});
});
+15 -2
View File
@@ -2,9 +2,21 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
import { execSync } from 'child_process';
const APP_VERSION = process.env.VITE_APP_VERSION || 'unknown'; const isDev = process.env.NODE_ENV === 'development' || process.env.vite === 'true';
const APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
let APP_VERSION = process.env.VITE_APP_VERSION || 'unknown';
let APP_GITSHA = process.env.VITE_APP_GITSHA || 'unknown';
if (isDev) {
APP_VERSION = 'DEV';
try {
APP_GITSHA = execSync('git rev-parse --short HEAD').toString().trim();
} catch (e) {
APP_GITSHA = 'unknown';
}
}
export default defineConfig({ export default defineConfig({
base: './', base: './',
@@ -14,6 +26,7 @@ export default defineConfig({
__APP_GITSHA__: JSON.stringify(APP_GITSHA), __APP_GITSHA__: JSON.stringify(APP_GITSHA),
}, },
resolve: { resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.mjs', '.js', '.json'],
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
+4 -4
View File
@@ -1,6 +1,6 @@
# Things I need to tell my AI to do # Things I need to tell my AI to do
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing - [/backend start] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing
- [ ] Add a test coverage metric to both frontend and backend so that we can check how much of the code is tested. I would like >=90% - [ ] Add a test coverage metric to both frontend and backend so that we can check how much of the code is tested. I would like >=90%
- [ ] Go over Claude's slop-cleanup-stuff - [ ] Go over Claude's slop-cleanup-stuff
- [x] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search) - [x] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search)
@@ -18,11 +18,11 @@ hint: Updates were rejected because the tag already exists in the remote.
exitcode '1': failure exitcode '1': failure
``` ```
- [ ] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone - [x] Editing a sleep session in "sleep sessions", when I click the edit button the times show up as UTC but should show as my local timezone
- [/] We should drop the "notes" column from the sleep_session table - [x] We should drop the "notes" column from the sleep_session table
- [x] Switch over to oh-my-opencode and try it out - [x] Switch over to oh-my-opencode and try it out
- [x] Adda footer at the bottom of the app that shows the version (aka git tag) of the app - [x] Adda footer at the bottom of the app that shows the version (aka git tag) of the app
- [-] Fix race condition of gitea workflows - [x] Fix race condition of gitea workflows
If both workflows run at the same time, a race condition occurs: If both workflows run at the same time, a race condition occurs:
1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6). 1. Collision: Both workflows will query the latest tag (e.g., v1.0.5) and calculate the same next version (v1.0.6).