/code-review high round 1 on S1b — 5 findings, 1 fixed, 4 by-design/deferred: - FIXED: VideoProgressIn / ItemProgressIn position_seconds & duration_seconds typed `int` would 422 a raw float currentTime on the 10s progress checkpoint (the old `int(payload.get(...))` truncated it). Back to `float`, truncated in the handler; +test. The live frontend floors so this was latent, not live. By-design / deferred (no change): the 422 detail-array isn't localized by the FE (filed under R7); PATCH is-not-none (channels/quota, null-safe) vs model_fields_set (nullable-clear) is a deliberate split; missing-field→422 is the point of the epic; me/switch_account's numeric-string coercion is more correct and still session-gated. Gate: ruff clean, pytest 172 green.
68 lines
2.7 KiB
Python
68 lines
2.7 KiB
Python
"""R6 S1b: the request models turn payloads that used to 500 into clean validation errors,
|
|
and PATCH models distinguish an absent field from an explicit null. Pure-logic (no DB) — it
|
|
exercises the Pydantic models the route modules define, not the endpoints."""
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.routes.channels import AttachTagIn, ChannelUpdateIn
|
|
from app.routes.downloads import AdminSetQuotaIn
|
|
from app.routes.feed import VideoProgressIn
|
|
from app.routes.setup import SetupAdminIn
|
|
from app.routes.tags import TagUpdateIn
|
|
|
|
|
|
# --- The three ROADMAP-named 500-producers are now 422s (ValidationError at the boundary). ---
|
|
|
|
def test_channel_priority_rejects_non_numeric():
|
|
# Was `int(payload["priority"])` → 500 on garbage. Now a validation error.
|
|
with pytest.raises(ValidationError):
|
|
ChannelUpdateIn(priority="not-a-number")
|
|
|
|
|
|
def test_channel_priority_accepts_int_and_numeric_string():
|
|
assert ChannelUpdateIn(priority=5).priority == 5
|
|
assert ChannelUpdateIn(priority="5").priority == 5 # lax numeric-string coercion, as before
|
|
|
|
|
|
def test_channel_update_fields_all_optional():
|
|
m = ChannelUpdateIn()
|
|
assert m.priority is None and m.hidden is None and m.deep_requested is None
|
|
|
|
|
|
def test_attach_tag_rejects_non_int_id():
|
|
# Was a str tag_id into db.get(Tag, ...) — a DB cast 500. Now rejected up front.
|
|
with pytest.raises(ValidationError):
|
|
AttachTagIn(tag_id="abc")
|
|
|
|
|
|
def test_setup_admin_password_must_be_a_string():
|
|
# Was a non-string password reaching len()/argon2 → 500. `str` rejects it.
|
|
with pytest.raises(ValidationError):
|
|
SetupAdminIn(email="a@b.c", password=12345)
|
|
|
|
|
|
def test_admin_quota_rejects_non_numeric_limit():
|
|
# Was `int(payload["max_bytes"])` → 500. Now a validation error.
|
|
with pytest.raises(ValidationError):
|
|
AdminSetQuotaIn(max_bytes="lots")
|
|
|
|
|
|
def test_progress_accepts_fractional_seconds():
|
|
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
|
|
# handler) — a plain `int` field would 422 on the progress hot path. (code-review round 1.)
|
|
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
|
|
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
|
|
with pytest.raises(ValidationError):
|
|
VideoProgressIn(position_seconds="not-a-number")
|
|
|
|
|
|
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
|
|
|
|
def test_tag_update_absent_vs_null_color():
|
|
absent = TagUpdateIn()
|
|
assert "color" not in absent.model_fields_set # untouched on PATCH
|
|
|
|
cleared = TagUpdateIn(color=None)
|
|
assert "color" in cleared.model_fields_set # an explicit null CLEARS it
|
|
assert cleared.color is None
|