"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42).""" from datetime import datetime, timezone from unittest.mock import patch from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from src.backend.models.orm.user import User from src.backend.services import report_service from src.backend.tests.conftest import auth_headers, create_test_recipe from src.backend.utils.tempo import as_utc_naive, to_local, utcnow def test_utcnow_naive_and_close_to_utc(): now = utcnow() assert now.tzinfo is None delta = datetime.now(timezone.utc).replace(tzinfo=None) - now assert abs(delta.total_seconds()) < 5 def test_as_utc_naive(): parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00") assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0) assert as_utc_naive(parsed).tzinfo is None naive = datetime(2026, 10, 6, 8, 0) assert as_utc_naive(naive) is naive assert as_utc_naive(None) is None def test_to_local_is_rome(): assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200" assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100" async def _make_measurement(client, user, recipe_id) -> None: tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json() await client.post( "/api/measurements/", headers=auth_headers(user), json={ "subtask_id": tasks[0]["subtasks"][0]["id"], "version_id": tasks[0]["version_id"], "value": 10.0, }, ) async def test_api_datetimes_carry_utc_offset_and_filter_equivalence( client: AsyncClient, measurement_tec_user: User, metrologist_user: User, db_session: AsyncSession, ): recipe = await create_test_recipe(db_session, measurement_tec_user.id) await _make_measurement(client, measurement_tec_user, recipe.id) resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user)) item = resp.json()["items"][0] assert item["measured_at"].endswith("+00:00") # Same instant written with +02:00 and as UTC: identical results. future_utc = "2099-01-01T08:00:00+00:00" future_rome = "2099-01-01T10:00:00+02:00" past_utc = "2000-01-01T08:00:00+00:00" past_rome = "2000-01-01T10:00:00+02:00" for frm, to in ((past_utc, future_utc), (past_rome, future_rome)): r = await client.get( "/api/measurements/", headers=auth_headers(metrologist_user), params={"date_from": frm, "date_to": to}, ) assert r.status_code == 200 assert r.json()["total"] == 1 # Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC, # written as +02:00 (now+3h local wall clock), must exclude it. ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc) from datetime import timedelta ahead_utc = (ahead + timedelta(hours=1)).isoformat() ahead_rome = (ahead + timedelta(hours=1)).astimezone( timezone(timedelta(hours=2)) ).isoformat() totals = [] for frm in (ahead_utc, ahead_rome): r = await client.get( "/api/measurements/", headers=auth_headers(metrologist_user), params={"date_from": frm}, ) totals.append(r.json()["total"]) assert totals == [0, 0] async def test_report_generated_at_is_local_time( db_session: AsyncSession, metrologist_user: User ): recipe = await create_test_recipe(db_session, metrologist_user.id) captured: dict[str, str] = {} def fake_render(html: str) -> bytes: captured["html"] = html return b"%PDF" with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \ patch.object(report_service, "_render_pdf", side_effect=fake_render): await report_service.generate_measurement_report(db_session, recipe.id) assert "Generated 2026-10-06 10:00" in captured["html"] def test_report_filter_day_is_local(): """UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42).""" assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"