diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 8e20970..fbe746f 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -1,11 +1,17 @@ """Tags API: autocomplete, create, list/add/remove for an image.""" from quart import Blueprint, jsonify, request +from sqlalchemy import exists, select from ..extensions import get_session from ..models import TagKind +from ..models.tag_allowlist import TagAllowlist from ..services.tag_directory_service import TagDirectoryService -from ..services.tag_service import TagService, TagValidationError +from ..services.tag_service import ( + TagMergeConflict, + TagService, + TagValidationError, +) tags_bp = Blueprint("tags", __name__, url_prefix="/api") @@ -138,12 +144,57 @@ async def rename_tag(tag_id: int): svc = TagService(session) try: tag = await svc.rename(tag_id, body["name"]) + except TagMergeConflict as exc: + return jsonify( + { + "error": str(exc), + "target": { + "id": exc.target_id, + "name": exc.target_name, + }, + "source_image_count": exc.source_image_count, + "will_alias": exc.will_alias, + } + ), 409 except TagValidationError as exc: - # Collision → 409 so the frontend can show the FC-2c merge hint. - msg = str(exc) - status = 409 if "already exists" in msg else 400 - return jsonify({"error": msg}), status + return jsonify({"error": str(exc)}), 400 await session.commit() return jsonify( {"id": tag.id, "name": tag.name, "kind": tag.kind.value} ) + + +@tags_bp.route("/tags//merge", methods=["POST"]) +async def merge_tag(source_id: int): + body = await request.get_json() + if not body or "target_id" not in body: + return jsonify({"error": "target_id required"}), 400 + target_id = body["target_id"] + async with get_session() as session: + svc = TagService(session) + try: + result = await svc.merge(source_id, target_id) + except TagValidationError as exc: + msg = str(exc) + status = 404 if "not found" in msg else 400 + return jsonify({"error": msg}), status + await session.commit() + target_allowlisted = await session.scalar( + select(exists().where(TagAllowlist.tag_id == result.target_id)) + ) + if target_allowlisted: + from ..tasks.ml import apply_allowlist_tags + + apply_allowlist_tags.delay(tag_id=result.target_id) + return jsonify( + { + "target": { + "id": result.target_id, + "name": result.target_name, + "kind": result.target_kind, + }, + "merged_count": result.merged_count, + "alias_created": result.alias_created, + "source_deleted": result.source_deleted, + } + ) diff --git a/tests/test_api_tag_merge.py b/tests/test_api_tag_merge.py new file mode 100644 index 0000000..8b8dd15 --- /dev/null +++ b/tests/test_api_tag_merge.py @@ -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