feat(api): rich 409 merge hint and POST tags merge endpoint with backfill enqueue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:40:37 -04:00
parent 8fbeabe9ba
commit 305687dc33
2 changed files with 168 additions and 5 deletions
+112
View File
@@ -0,0 +1,112 @@
import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
async def _mk(client, name, kind):
r = await client.post("/api/tags", json={"name": name, "kind": kind})
return (await r.get_json())["id"]
@pytest.mark.asyncio
async def test_rename_collision_returns_rich_409(client):
tgt = await _mk(client, "Canonical", "general")
src = await _mk(client, "Duplicate", "general")
resp = await client.patch(f"/api/tags/{src}", json={"name": "Canonical"})
assert resp.status_code == 409
body = await resp.get_json()
assert body["target"]["id"] == tgt
assert body["target"]["name"] == "Canonical"
assert body["source_image_count"] == 0
assert body["will_alias"] is False
@pytest.mark.asyncio
async def test_merge_endpoint_moves_and_deletes(client, monkeypatch):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "Keep", "general")
src = await _mk(client, "Gone", "general")
resp = await client.post(
f"/api/tags/{src}/merge", json={"target_id": tgt}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["target"]["id"] == tgt
assert body["source_deleted"] is True
# source is gone: renaming something else onto its old name no longer collides
other = await _mk(client, "Other", "general")
r2 = await client.patch(f"/api/tags/{other}", json={"name": "Gone"})
assert r2.status_code == 200
@pytest.mark.asyncio
async def test_merge_enqueues_backfill_when_target_allowlisted(
client, monkeypatch
):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "AllowTgt", "general")
src = await _mk(client, "AllowSrc", "general")
# No public route adds a tag to the allowlist (it happens via
# accept-suggestion); set the row directly through the app session.
from backend.app.extensions import get_session
from backend.app.models.tag_allowlist import TagAllowlist
async with get_session() as s:
s.add(TagAllowlist(tag_id=tgt))
await s.commit()
resp = await client.post(
f"/api/tags/{src}/merge", json={"target_id": tgt}
)
assert resp.status_code == 200
assert calls == [{"tag_id": tgt}]
@pytest.mark.asyncio
async def test_merge_self_is_400(client):
t = await _mk(client, "Selfie", "general")
resp = await client.post(f"/api/tags/{t}/merge", json={"target_id": t})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_merge_missing_target_is_404(client):
t = await _mk(client, "Lonely", "general")
resp = await client.post(
f"/api/tags/{t}/merge", json={"target_id": 999999}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_merge_requires_target_id(client):
t = await _mk(client, "NoBody", "general")
resp = await client.post(f"/api/tags/{t}/merge", json={})
assert resp.status_code == 400