body
", + ) + db.add(p) + await db.flush() + # Seed 10 ImageRecord rows linked to this post via primary_post_id. + for i in range(10): + sha = f"y{i:x}".ljust(64, "0")[:64] + rec = ImageRecord( + path=f"/images/test-yuki-{i}.jpg", + sha256=sha, + size_bytes=1, + mime="image/jpeg", + width=64, + height=64, + origin="downloaded", + integrity_status="unknown", + primary_post_id=p.id, + artist_id=a.id, + ) + db.add(rec) + await db.commit() + + resp = await client.get(f"/api/posts/{p.id}") + assert resp.status_code == 200 + body = await resp.get_json() + # Detail returns ALL 10 thumbnails (feed would return 6 + thumbnails_more). + assert len(body["thumbnails"]) == 10 + assert body["description_full"] == "body" diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index 39b45a9..3322556 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="character:Saber"` without explicit kind → parsed as character.""" + resp = await client.post("/api/tags", json={"name": "character:Saber"}) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "Saber" + assert body["kind"] == "character" + + +@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": "character:Saber", "kind": "general"} + ) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["name"] == "character:Saber" + 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" diff --git a/tests/test_migration_0002.py b/tests/test_migration_0002.py index 6b8d13c..1786731 100644 --- a/tests/test_migration_0002.py +++ b/tests/test_migration_0002.py @@ -25,6 +25,10 @@ def test_tag_has_kind_and_fandom_id(): def test_tag_kind_enum_values(): + # Current TagKind enum after alembic 0023 dropped meta + rating + # (operator-retired 2026-05-26). `artist` is still in the enum + # for backward-compat with historical rows, though new artist + # tags don't get created (Artist row is canonical per FC-2d-vii-c). expected = { "artist", "character", @@ -33,8 +37,6 @@ def test_tag_kind_enum_values(): "series", "archive", "post", - "meta", - "rating", } assert {k.value for k in TagKind} == expected diff --git a/tests/test_tag_prefix.py b/tests/test_tag_prefix.py new file mode 100644 index 0000000..192dca5 --- /dev/null +++ b/tests/test_tag_prefix.py @@ -0,0 +1,54 @@ +"""parse_kind_prefix — IR-style `kind:name` shortcut at the input boundary.""" + +from backend.app.utils.tag_prefix import KNOWN_KINDS, parse_kind_prefix + + +def test_recognized_kinds_match_user_input_set(): + # Excluded: archive + post (system-managed), general (default for + # un-prefixed input), artist (retired in FC-2d-vii-c — first-class + # entities now), meta + rating (retired as user-typeable per + # operator 2026-05-26). + assert KNOWN_KINDS == frozenset({ + "character", "fandom", "series", + }) + + +def test_character_prefix_parsed(): + assert parse_kind_prefix("character:Saber") == ("character", "Saber") + + +def test_case_insensitive_prefix(): + assert parse_kind_prefix("Character:Saber") == ("character", "Saber") + assert parse_kind_prefix("CHARACTER:Saber") == ("character", "Saber") + + +def test_no_prefix_returns_none_kind(): + assert parse_kind_prefix("sunset") == (None, "sunset") + + +def test_unknown_prefix_kept_as_literal(): + # 'http' is not a known kind — preserve the literal text. + assert parse_kind_prefix("http://example.com") == (None, "http://example.com") + + +def test_retired_prefixes_kept_as_literal(): + # `artist:`, `meta:`, `rating:` are no longer recognized — they + # parse as literal text so the operator's input is preserved (and + # serves as a nudge to use the appropriate dedicated UI instead). + assert parse_kind_prefix("artist:Eric") == (None, "artist:Eric") + assert parse_kind_prefix("meta:wide") == (None, "meta:wide") + assert parse_kind_prefix("rating:safe") == (None, "rating:safe") + + +def test_whitespace_stripped(): + assert parse_kind_prefix("series: Bleach ") == ("series", "Bleach") + assert parse_kind_prefix(" sunset ") == (None, "sunset") + + +def test_empty_string(): + assert parse_kind_prefix("") == (None, "") + + +def test_just_colon(): + # Empty prefix → "" not in KNOWN_KINDS → falls through to (None, "...") + assert parse_kind_prefix(":foo") == (None, ":foo")