feat(bulk): BulkTagService (common_tags/bulk_add/bulk_remove) + 3 image bulk-tag routes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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_tag(client, name, kind="general"):
|
||||
r = await client.post("/api/tags", json={"name": name, "kind": kind})
|
||||
return (await r.get_json())["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_tags_route(client):
|
||||
resp = await client.post("/api/images/common-tags", json={"image_ids": []})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["tags"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_tags_requires_list(client):
|
||||
resp = await client.post("/api/images/common-tags", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_route_happy(client):
|
||||
tag = await _mk_tag(client, "RouteAdd")
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags",
|
||||
json={"image_ids": [1, 2, 3], "tag_id": tag},
|
||||
)
|
||||
# images 1..3 may not exist; FK means added_count reflects real images.
|
||||
assert resp.status_code in (200, 400)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_rejects_bad_source(client):
|
||||
tag = await _mk_tag(client, "RouteAddSrc")
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags",
|
||||
json={"image_ids": [1], "tag_id": tag, "source": "evil"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_missing_tag_404(client):
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags",
|
||||
json={"image_ids": [1], "tag_id": 999999},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_over_cap_400(client):
|
||||
tag = await _mk_tag(client, "RouteAddCap")
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags",
|
||||
json={"image_ids": list(range(1, 202)), "tag_id": tag},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_remove_route_missing_tag_404(client):
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags/remove",
|
||||
json={"image_ids": [1], "tag_id": 999999},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_remove_route_requires_ids(client):
|
||||
resp = await client.post(
|
||||
"/api/images/bulk/tags/remove", json={"tag_id": 1}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import ImageRecord, TagKind
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.models.tag_suggestion_rejection import TagSuggestionRejection
|
||||
from backend.app.services.bulk_tag_service import BulkTagService
|
||||
from backend.app.services.tag_service import TagService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SEQ = 0
|
||||
|
||||
|
||||
async def _img(db):
|
||||
global _SEQ
|
||||
_SEQ += 1
|
||||
rec = ImageRecord(
|
||||
path=f"/tmp/fc_bulk_{_SEQ}.png",
|
||||
sha256=f"{_SEQ:064d}",
|
||||
size_bytes=1,
|
||||
mime="image/png",
|
||||
origin="uploaded",
|
||||
)
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_tags_only_returns_tags_on_every_image(db):
|
||||
tags = TagService(db)
|
||||
t_all = await tags.find_or_create("OnAll", TagKind.general)
|
||||
t_some = await tags.find_or_create("OnSome", TagKind.general)
|
||||
i1, i2 = await _img(db), await _img(db)
|
||||
await tags.add_to_image(i1, t_all.id)
|
||||
await tags.add_to_image(i2, t_all.id)
|
||||
await tags.add_to_image(i1, t_some.id) # only one image
|
||||
svc = BulkTagService(db)
|
||||
result = await svc.common_tags([i1, i2])
|
||||
assert [t["id"] for t in result] == [t_all.id]
|
||||
assert result[0]["name"] == "OnAll"
|
||||
assert result[0]["kind"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_tags_empty_when_no_images(db):
|
||||
svc = BulkTagService(db)
|
||||
assert await svc.common_tags([]) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_applies_and_counts_idempotent(db):
|
||||
tags = TagService(db)
|
||||
tag = await tags.find_or_create("BulkAdd", TagKind.general)
|
||||
i1, i2 = await _img(db), await _img(db)
|
||||
svc = BulkTagService(db)
|
||||
added = await svc.bulk_add([i1, i2], tag.id)
|
||||
assert added == 2
|
||||
# Idempotent: re-adding adds nothing.
|
||||
again = await svc.bulk_add([i1, i2], tag.id)
|
||||
assert again == 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(image_tag.c.image_record_id, image_tag.c.source).where(
|
||||
image_tag.c.tag_id == tag.id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
assert {r.image_record_id for r in rows} == {i1, i2}
|
||||
assert all(r.source == "manual" for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_add_records_explicit_source(db):
|
||||
tags = TagService(db)
|
||||
tag = await tags.find_or_create("BulkAddML", TagKind.general)
|
||||
i1 = await _img(db)
|
||||
svc = BulkTagService(db)
|
||||
await svc.bulk_add([i1], tag.id, source="ml_accepted")
|
||||
src = await db.scalar(
|
||||
select(image_tag.c.source).where(image_tag.c.tag_id == tag.id)
|
||||
)
|
||||
assert src == "ml_accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_remove_counts_and_records_rejections(db):
|
||||
tags = TagService(db)
|
||||
tag = await tags.find_or_create("BulkRem", TagKind.general)
|
||||
i1, i2 = await _img(db), await _img(db)
|
||||
svc = BulkTagService(db)
|
||||
await svc.bulk_add([i1, i2], tag.id)
|
||||
removed = await svc.bulk_remove([i1, i2], tag.id)
|
||||
assert removed == 2
|
||||
remaining = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(image_tag.c.tag_id == tag.id)
|
||||
)
|
||||
assert remaining == 0
|
||||
rej = (
|
||||
await db.execute(
|
||||
select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == tag.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert set(rej) == {i1, i2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_remove_rejection_is_idempotent(db):
|
||||
tags = TagService(db)
|
||||
tag = await tags.find_or_create("BulkRem2", TagKind.general)
|
||||
i1 = await _img(db)
|
||||
svc = BulkTagService(db)
|
||||
await svc.bulk_add([i1], tag.id)
|
||||
await svc.bulk_remove([i1], tag.id)
|
||||
# Second remove: nothing to delete, rejection already present, no error.
|
||||
removed = await svc.bulk_remove([i1], tag.id)
|
||||
assert removed == 0
|
||||
Reference in New Issue
Block a user