Files
Donkie e509378392 chore: require Python 3.14
The declared floor was ">=3.10", but nothing enforced it: the Docker image
has pinned python:3.14-slim-bookworm for amd64/arm64/armv7 for some time,
and scripts/install.sh runs "uv sync" without UV_PYTHON_DOWNLOADS=0, so uv
fetches a managed interpreter when the system one is too old. Every
dependency declares a floor of <=3.10 with no upper cap.

Raising it unlocks enum.StrEnum, datetime.UTC and typing.Self for the
authentication work that follows.

Consequences handled here:

- uv.lock regenerated. Four pure-backport packages drop out
  (async-timeout, backports-asyncio-runner, exceptiongroup, tomli).

- ruff target-version follows to py314, which switches on PEP 649 deferred
  annotations and with them a batch of previously-dormant rules. The safe
  automatic fixes are applied: datetime.timezone.utc -> datetime.UTC
  (UP017) and redundant quotes dropped from annotations (UP037).

- TC001/TC002/TC003 are ignored rather than applied. They want type-only
  imports moved into "if TYPE_CHECKING:" blocks, which is wrong here:
  FastAPI resolves Annotated[AsyncSession, Depends(...)] and websocket
  parameters at runtime, Pydantic resolves field types at runtime, and
  SQLAlchemy resolves Mapped[...] at mapper-configuration time. A
  TYPE_CHECKING-only import is absent from the runtime namespace, so
  applying them breaks dependency injection. Ruff classifies all 50 as
  unsafe fixes for that reason.

Verified: ruff check/format clean, uv lock --check clean, alembic head
resolves, SQLAlchemy mappers configure with all relationships intact and
lazy="selectin" preserved on the extra-field collections.
2026-07-27 20:09:43 +02:00

181 lines
5.5 KiB
Python

"""Integration tests for the Spool API endpoint."""
import asyncio
import math
from datetime import UTC, datetime
from typing import Any
import httpx
import pytest
from ..conftest import URL
@pytest.mark.parametrize("use_weight", [0, 0.05, -0.05, 1500]) # 1500 is big enough to use all filament
def test_use_spool_weight(random_filament: dict[str, Any], use_weight: float):
"""Test using a spool in the database."""
# Setup
filament_net_weight = random_filament["weight"]
start_weight = 1000
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/use",
json={
"use_weight": use_weight,
},
)
result.raise_for_status()
# Verify
spool = result.json()
# remaining_weight should be clamped so it's never negative, but used_weight should not be clamped to the net weight
assert spool["used_weight"] == pytest.approx(max(use_weight, 0))
assert spool["remaining_weight"] == pytest.approx(min(max(start_weight - use_weight, 0), filament_net_weight))
# Verify that first_used has been updated
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@pytest.mark.parametrize("use_length", [0, 10, -10, 500e3]) # 500e3 is big enough to use all the filament
def test_use_spool_length(random_filament: dict[str, Any], use_length: float):
"""Test using a spool in the database."""
# Setup
filament_net_weight = random_filament["weight"]
start_weight = 1000
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
"first_used": "2023-01-01T00:00:00Z",
},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/use",
json={
"use_length": use_length,
},
)
result.raise_for_status()
# Verify
spool = result.json()
use_weight = (
random_filament["density"] * (use_length * 1e-1) * math.pi * ((random_filament["diameter"] * 1e-1 / 2) ** 2)
)
# remaining_weight should be clamped so it's never negative, but used_weight should not be clamped to the net weight
assert spool["used_weight"] == pytest.approx(max(use_weight, 0))
assert spool["remaining_weight"] == pytest.approx(min(max(start_weight - use_weight, 0), filament_net_weight))
# Verify that first_used hasn't been updated since it was already set
assert spool["first_used"] == "2023-01-01T00:00:00Z"
# Verify that last_used has been updated
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_use_spool_weight_and_length(random_filament: dict[str, Any]):
"""Test using a spool in the database."""
# Setup
result = httpx.post(
f"{URL}/api/v1/spool",
json={"filament_id": random_filament["id"]},
)
result.raise_for_status()
spool = result.json()
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/{spool['id']}/use",
json={
"use_weight": 0.05,
"use_length": 10,
},
)
assert result.status_code == 400 # Cannot use both weight and length
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def test_use_spool_not_found():
"""Test using a spool that does not exist."""
# Execute
result = httpx.put(
f"{URL}/api/v1/spool/123456789/use",
json={"use_weight": 0.05},
)
assert result.status_code == 404
message = result.json()["message"].lower()
assert "spool" in message
assert "id" in message
assert "123456789" in message
@pytest.mark.asyncio
async def test_use_spool_concurrent(random_filament: dict[str, Any]):
"""Test using a spool with many concurrent requests."""
# Setup
start_weight = 1000
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": random_filament["id"],
"remaining_weight": start_weight,
},
)
result.raise_for_status()
spool = result.json()
# Execute
requests = 100
used_weight = 0.5
async with httpx.AsyncClient() as client:
await asyncio.gather(
*(
client.put(
f"{URL}/api/v1/spool/{spool['id']}/use",
json={
"use_weight": used_weight,
},
timeout=60,
)
for _ in range(requests)
),
)
# Verify
result = httpx.get(f"{URL}/api/v1/spool/{spool['id']}")
result.raise_for_status()
spool = result.json()
assert spool["remaining_weight"] == pytest.approx(start_weight - (used_weight * requests))
# Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()