diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e8d01d7..283786a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,12 @@ FROM mcr.microsoft.com/devcontainers/python:1-3.13-bookworm # `mysql` CLI for poking at the database during development. -RUN apt-get update \ +# +# The base image ships a yarn apt source whose signing key has since rotated, +# which makes `apt-get update` fail outright. We don't use yarn, so drop that +# source list rather than carrying its key. +RUN rm -f /etc/apt/sources.list.d/yarn.list \ + && apt-get update \ && apt-get install -y --no-install-recommends default-mysql-client \ && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/compose.override.yaml b/.devcontainer/compose.override.yaml index f17afdc..bf977c7 100644 --- a/.devcontainer/compose.override.yaml +++ b/.devcontainer/compose.override.yaml @@ -1,16 +1,20 @@ # Adds the development container to the stack defined in ../compose.yaml. # Merged by devcontainer.json; not useful on its own. +# +# NOTE: relative paths below resolve against the *project directory* -- the +# directory of the first compose file (the repo root), not this file's own +# directory. Hence "." rather than "..". services: app: build: - context: .. + context: . dockerfile: .devcontainer/Dockerfile command: sleep infinity ports: - "8000:8000" volumes: - - ..:/workspaces/v2x-server:cached + - .:/workspaces/v2x-server:cached # Keep the Linux venv out of the macOS/Windows bind mount. Without this, # host-side tooling and platform-specific wheels collide. - venv:/workspaces/v2x-server/.venv diff --git a/pyproject.toml b/pyproject.toml index 83f4b10..9098a63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,10 @@ ignore_missing_imports = true [tool.pytest.ini_options] asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" +# Must match the test loop scope (function). If fixtures run on a session loop +# while tests run on a per-function loop, asyncmy's loop-bound futures raise +# "attached to a different loop". aiosqlite hides this; MySQL does not. +asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] addopts = "-ra --strict-markers" filterwarnings = ["error"] diff --git a/src/v2x_server/schemas/device.py b/src/v2x_server/schemas/device.py index 6e4cec4..fcaaf8f 100644 --- a/src/v2x_server/schemas/device.py +++ b/src/v2x_server/schemas/device.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from v2x_server.models.device import DeviceStatus @@ -40,6 +40,18 @@ class DeviceRead(DeviceBase): created_at: dt.datetime updated_at: dt.datetime + @field_validator("created_at", "updated_at") + @classmethod + def _mark_as_utc(cls, value: dt.datetime) -> dt.datetime: + """Attach the offset MySQL cannot store. + + DATETIME columns carry no timezone, so these come back naive. Every + connection is pinned to UTC (see db/session.py), so a naive value read + from the database *is* UTC -- say so explicitly rather than emitting a + bare timestamp the client has to guess about. + """ + return value.replace(tzinfo=dt.UTC) if value.tzinfo is None else value + class DevicePage(BaseModel): items: list[DeviceRead] diff --git a/tests/test_devices_api.py b/tests/test_devices_api.py index f31ba59..138becd 100644 --- a/tests/test_devices_api.py +++ b/tests/test_devices_api.py @@ -101,6 +101,14 @@ class TestCrud: assert patched.json()["description"] == "original" assert patched.json()["name"] == created["name"] + async def test_timestamps_carry_an_explicit_utc_offset( + self, client: AsyncClient, as_user: AsUser + ) -> None: + """MySQL DATETIME has no offset; the API must not emit a bare timestamp.""" + as_user("operator") + created = (await client.post(PREFIX, json=_payload())).json() + assert created["created_at"].endswith(("Z", "+00:00")) + async def test_missing_device_is_404(self, client: AsyncClient, as_user: AsUser) -> None: as_user("viewer") response = await client.get(f"{PREFIX}/999999")