mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 18:12:18 -05:00
Picking the empty option of a single-choice extra field did nothing --
in either client, on spools, filaments and vendors alike. Emptying an
integer, range, datetime or multi-choice field was just as stuck. There
was no way to get a value back to unset once it had one.
Nothing in the API could express it. A key left out of the map means
"leave it alone", and every value that could stand in for empty is
rejected by the field's type check: "" is not one of a choice field's
choices. So `extra` values now accept null, meaning the entity has no
value for that field -- no row is stored, which clears whatever was
there. Null was a 422 before it reached any logic, so nothing that used
to work behaves differently.
A filament's and a vendor's extra were also REPLACED wholesale by a
patch rather than merged per key, unlike a spool's since 28f6f8e
(v0.22.0). Anyone patching one field of a filament -- as client_v2 did
-- silently destroyed the rest of its custom fields; anyone relying on
the replacement to delete a key can now say so with null. All three
entities merge per key now, and the field descriptions say so instead
of saying nothing.
client_v2's saver was where the frontend half died: an emptied input
produced no patch at all. It sends the cleared key as null now, and
keeps sending only the keys that changed, which is what stops two people
editing different fields of one entity from overwriting each other. It
also captures the entity id when the edit is made -- the inspectors are
reused across selections rather than remounted, so a debounced patch
could otherwise land on whatever you clicked next.
The legacy client cleared a filament's or vendor's field by omitting it,
which the merge would now ignore, so its form sends null for a field
left empty. That also buys it clearing on spools, which merged (and so
ignored the omission) all along.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
212 lines
9.1 KiB
Python
212 lines
9.1 KiB
Python
"""Tests for the update semantics of an entity's extra fields.
|
|
|
|
A PATCH merges the map per key: a field left out of it keeps whatever it held. A value can
|
|
be set to any JSON-encoded string of the field's type, and null says "this entity has no
|
|
value for the field", which is what removes one that has been set. Without null there is no
|
|
way back to unset — an omitted key means "leave it alone", and the type check rejects every
|
|
value that could stand in for "empty" (a single-choice field, for instance, only accepts one
|
|
of its choices).
|
|
|
|
All three entities behave the same way; a filament's and a vendor's extra used to be
|
|
replaced wholesale instead, which silently dropped the values a patch didn't mention.
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from ..conftest import URL, assert_httpx_success
|
|
|
|
|
|
def _create_entity(entity_type: str, extra: dict[str, Any], random_filament: dict[str, Any]) -> int:
|
|
"""Create a test entity of the given type with the given extra fields. Returns the entity id."""
|
|
if entity_type == "spool":
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/spool",
|
|
json={"filament_id": random_filament["id"], "extra": extra},
|
|
)
|
|
elif entity_type == "filament":
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/filament",
|
|
json={
|
|
"vendor_id": random_filament["vendor"]["id"],
|
|
"name": f"Test-{uuid.uuid4().hex[:8]}",
|
|
"density": 1.24,
|
|
"diameter": 1.75,
|
|
"extra": extra,
|
|
},
|
|
)
|
|
elif entity_type == "vendor":
|
|
result = httpx.post(
|
|
f"{URL}/api/v1/vendor",
|
|
json={"name": f"Vendor-{uuid.uuid4().hex[:8]}", "extra": extra},
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown entity type: {entity_type}")
|
|
result.raise_for_status()
|
|
return result.json()["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_clear_choice_value(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""A single-choice value is removed by patching the key with null."""
|
|
field_key = f"clear_choice_{uuid.uuid4().hex[:8]}"
|
|
httpx.post(
|
|
f"{URL}/api/v1/field/{entity_type}/{field_key}",
|
|
json={"name": "Clear choice", "field_type": "choice", "choices": ["Good", "Bad"], "multi_choice": False},
|
|
).raise_for_status()
|
|
entity_id = _create_entity(entity_type, {field_key: json.dumps("Good")}, random_filament)
|
|
try:
|
|
result = httpx.patch(f"{URL}/api/v1/{entity_type}/{entity_id}", json={"extra": {field_key: None}})
|
|
assert_httpx_success(result)
|
|
assert field_key not in result.json()["extra"]
|
|
|
|
# …and it stayed gone.
|
|
result = httpx.get(f"{URL}/api/v1/{entity_type}/{entity_id}")
|
|
assert_httpx_success(result)
|
|
assert field_key not in result.json()["extra"]
|
|
finally:
|
|
httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status()
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_patch_merges_per_key(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""A patch that mentions one field leaves the entity's other fields as they were."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
patched_key = f"merge_a_{suffix}"
|
|
untouched_key = f"merge_b_{suffix}"
|
|
for key in (patched_key, untouched_key):
|
|
httpx.post(
|
|
f"{URL}/api/v1/field/{entity_type}/{key}",
|
|
json={"name": key, "field_type": "text"},
|
|
).raise_for_status()
|
|
entity_id = _create_entity(
|
|
entity_type,
|
|
{patched_key: json.dumps("before"), untouched_key: json.dumps("untouched")},
|
|
random_filament,
|
|
)
|
|
try:
|
|
result = httpx.patch(
|
|
f"{URL}/api/v1/{entity_type}/{entity_id}",
|
|
json={"extra": {patched_key: json.dumps("after")}},
|
|
)
|
|
assert_httpx_success(result)
|
|
assert result.json()["extra"] == {
|
|
patched_key: json.dumps("after"),
|
|
untouched_key: json.dumps("untouched"),
|
|
}
|
|
finally:
|
|
for key in (patched_key, untouched_key):
|
|
httpx.delete(f"{URL}/api/v1/field/{entity_type}/{key}").raise_for_status()
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_clear_leaves_other_values_alone(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""Clearing one field in a patch that also carries another does not disturb the other."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
cleared_key = f"clear_a_{suffix}"
|
|
kept_key = f"clear_b_{suffix}"
|
|
for key in (cleared_key, kept_key):
|
|
httpx.post(
|
|
f"{URL}/api/v1/field/{entity_type}/{key}",
|
|
json={"name": key, "field_type": "text"},
|
|
).raise_for_status()
|
|
entity_id = _create_entity(
|
|
entity_type,
|
|
{cleared_key: json.dumps("gone"), kept_key: json.dumps("stays")},
|
|
random_filament,
|
|
)
|
|
try:
|
|
result = httpx.patch(
|
|
f"{URL}/api/v1/{entity_type}/{entity_id}",
|
|
json={"extra": {cleared_key: None, kept_key: json.dumps("stays")}},
|
|
)
|
|
assert_httpx_success(result)
|
|
assert result.json()["extra"] == {kept_key: json.dumps("stays")}
|
|
finally:
|
|
for key in (cleared_key, kept_key):
|
|
httpx.delete(f"{URL}/api/v1/field/{entity_type}/{key}").raise_for_status()
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_clear_every_field_type(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""Null clears a value of any field type, not just the ones with a JSON string value."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
fields = {
|
|
f"clr_text_{suffix}": ({"field_type": "text"}, json.dumps("hello")),
|
|
f"clr_int_{suffix}": ({"field_type": "integer"}, json.dumps(42)),
|
|
f"clr_range_{suffix}": ({"field_type": "float_range"}, json.dumps([1.0, 2.0])),
|
|
# "true" is what json.dumps(True) gives; spelled out to keep the wire value obvious.
|
|
f"clr_bool_{suffix}": ({"field_type": "boolean"}, "true"),
|
|
f"clr_date_{suffix}": ({"field_type": "datetime"}, json.dumps("2024-01-02T03:04:05")),
|
|
f"clr_multi_{suffix}": (
|
|
{"field_type": "choice", "choices": ["a", "b"], "multi_choice": True},
|
|
json.dumps(["a"]),
|
|
),
|
|
}
|
|
for key, (params, _) in fields.items():
|
|
httpx.post(
|
|
f"{URL}/api/v1/field/{entity_type}/{key}",
|
|
json={"name": key, **params},
|
|
).raise_for_status()
|
|
entity_id = _create_entity(entity_type, {k: v for k, (_, v) in fields.items()}, random_filament)
|
|
try:
|
|
result = httpx.get(f"{URL}/api/v1/{entity_type}/{entity_id}")
|
|
assert_httpx_success(result)
|
|
assert set(result.json()["extra"]) == set(fields)
|
|
|
|
result = httpx.patch(
|
|
f"{URL}/api/v1/{entity_type}/{entity_id}",
|
|
json={"extra": dict.fromkeys(fields)},
|
|
)
|
|
assert_httpx_success(result)
|
|
assert result.json()["extra"] == {}
|
|
finally:
|
|
for key in fields:
|
|
httpx.delete(f"{URL}/api/v1/field/{entity_type}/{key}").raise_for_status()
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_create_with_null_value_stores_nothing(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""A null value on create means the entity simply has no value for that field."""
|
|
field_key = f"clear_new_{uuid.uuid4().hex[:8]}"
|
|
httpx.post(
|
|
f"{URL}/api/v1/field/{entity_type}/{field_key}",
|
|
json={"name": "Clear on create", "field_type": "text"},
|
|
).raise_for_status()
|
|
entity_id = _create_entity(entity_type, {field_key: None}, random_filament)
|
|
try:
|
|
result = httpx.get(f"{URL}/api/v1/{entity_type}/{entity_id}")
|
|
assert_httpx_success(result)
|
|
assert field_key not in result.json()["extra"]
|
|
finally:
|
|
httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status()
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"])
|
|
async def test_unknown_field_is_still_rejected(entity_type: str, random_filament: dict[str, Any]) -> None:
|
|
"""Null does not buy a pass on the key check: an unknown field is still a 400."""
|
|
entity_id = _create_entity(entity_type, {}, random_filament)
|
|
try:
|
|
result = httpx.patch(
|
|
f"{URL}/api/v1/{entity_type}/{entity_id}",
|
|
json={"extra": {f"nonexistent_{uuid.uuid4().hex[:8]}": None}},
|
|
)
|
|
assert result.status_code == 400
|
|
finally:
|
|
httpx.delete(f"{URL}/api/v1/{entity_type}/{entity_id}").raise_for_status()
|