From 8cdf0af0e1ceeebc88f542ce9d9f18d46cad3744 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:53:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(tags-api):=20IR-style=20kind:name=20parsin?= =?UTF-8?q?g=20at=20POST=20/api/tags=20=E2=80=94=20when=20caller=20doesn't?= =?UTF-8?q?=20supply=20explicit=20kind,=20parse=5Fkind=5Fprefix=20runs=20o?= =?UTF-8?q?n=20the=20name=20(artist:Eric=20=E2=86=92=20kind=3Dartist,=20na?= =?UTF-8?q?me=3D'Eric');=20explicit=20kind=20always=20wins=20for=20backwar?= =?UTF-8?q?d-compat;=20falls=20back=20to=20general=20when=20no=20recognize?= =?UTF-8?q?d=20prefix=20is=20present.=20Updates=20the=20old=20"missing=20r?= =?UTF-8?q?equired"=20test=20that=20assumed=20kind=20was=20mandatory.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/tags.py | 37 +++++++++++++++++++++++++----- tests/test_api_tags.py | 50 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 64f16bf..e18d94d 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -15,6 +15,7 @@ from ..services.tag_service import ( TagService, TagValidationError, ) +from ..utils.tag_prefix import parse_kind_prefix tags_bp = Blueprint("tags", __name__, url_prefix="/api") @@ -105,13 +106,39 @@ async def directory(): @tags_bp.route("/tags", methods=["POST"]) async def create_tag(): + """Create a tag. Two input shapes accepted: + 1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins. + 2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric"). + The server runs parse_kind_prefix(name) to derive kind; the colon + and prefix are stripped from the stored tag name. If no recognized + prefix is present, the kind defaults to `general`. + Explicit kind ALWAYS wins (backward-compat for existing callers). + """ body = await request.get_json() - if not body or "name" not in body or "kind" not in body: - return jsonify({"error": "name and kind required"}), 400 + if not body or "name" not in body: + return jsonify({"error": "name required"}), 400 name = body["name"] - kind = _coerce_kind(body["kind"]) - if kind is None: - return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400 + explicit_kind_raw = body.get("kind") + + if explicit_kind_raw is not None: + # Caller provided kind — honor it; don't re-parse. + kind = _coerce_kind(explicit_kind_raw) + if kind is None: + return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400 + else: + # IR-style: parse "kind:Name" from the raw name. + parsed_kind, parsed_name = parse_kind_prefix(name) + if parsed_kind is not None: + name = parsed_name + kind = _coerce_kind(parsed_kind) + # parse_kind_prefix only returns kinds from KNOWN_KINDS which + # are all valid TagKind members, so _coerce_kind can't return + # None here — but defensive. + if kind is None: + return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400 + else: + kind = TagKind.general + fandom_id = body.get("fandom_id") async with get_session() as session: diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index 39b45a9..4f86b86 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -55,6 +55,52 @@ async def test_create_character_with_bad_fandom_id(client): @pytest.mark.asyncio -async def test_create_tag_missing_required(client): - resp = await client.post("/api/tags", json={"name": "Bob"}) +async def test_create_tag_missing_name_400(client): + """name is still required; only `kind` became optional.""" + resp = await client.post("/api/tags", json={}) assert resp.status_code == 400 + resp = await client.post("/api/tags", json={"kind": "artist"}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_create_tag_name_only_defaults_to_general(client): + """IR-style: name without kind and without `kind:` prefix → general.""" + resp = await client.post("/api/tags", json={"name": "sunset"}) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "sunset" + assert body["kind"] == "general" + + +@pytest.mark.asyncio +async def test_create_tag_with_kind_prefix(client): + """IR-style: `name="artist:Eric"` without explicit kind → parsed as artist.""" + resp = await client.post("/api/tags", json={"name": "artist:Eric"}) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "Eric" + assert body["kind"] == "artist" + + +@pytest.mark.asyncio +async def test_explicit_kind_overrides_prefix_parsing(client): + """If caller passes explicit kind, don't re-parse the name — + colon and prefix stay literal.""" + resp = await client.post( + "/api/tags", json={"name": "artist:Eric", "kind": "general"} + ) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "artist:Eric" + assert body["kind"] == "general" + + +@pytest.mark.asyncio +async def test_unknown_prefix_kept_literal(client): + """`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name.""" + resp = await client.post("/api/tags", json={"name": "http://example.com"}) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "http://example.com" + assert body["kind"] == "general"