diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 2cd12c7e..20def162 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -32,9 +32,9 @@ async def capabilities() -> dict[str, Any]: "vision": settings.effective_ai_vision_enabled, "text": settings.effective_ai_text_enabled, }, - # Set to True in PR 2-3 when agent write-back endpoints exist. + # Set to True when the agent write-back endpoints for outfits exist. "features": { - "external_tagging": False, + "external_tagging": True, "external_suggestions": False, "external_pairings": False, }, diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 3c2e7271..4f055d43 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -11,6 +11,7 @@ from app.config import get_settings from app.database import get_db +from app.models.item import ItemStatus, TaggedBy, TaggingStatus from app.models.user import User from app.schemas.item import ( ArchiveRequest, @@ -37,6 +38,8 @@ from app.utils.auth import get_current_user from app.workers.settings import get_redis_settings +TAG_WRITEBACK_FIELDS = {"type", "subtype", "tags", "colors", "primary_color"} + logger = logging.getLogger(__name__) settings = get_settings() @@ -53,6 +56,7 @@ async def list_items( subtype: str | None = None, colors: str | None = None, status: str | None = None, + tagging_status: str | None = None, favorite: bool | None = None, needs_wash: bool | None = None, is_archived: bool = False, @@ -67,6 +71,7 @@ async def list_items( subtype=subtype, colors=color_list, status=status, + tagging_status=tagging_status, favorite=favorite, needs_wash=needs_wash, is_archived=is_archived, @@ -105,6 +110,7 @@ async def create_item( colors: str | None = Form(None), primary_color: str | None = Form(None), favorite: bool = Form(False), + auto_tag: bool | None = Form(None), ) -> ItemResponse: # Validate and process image image_service = ImageService() @@ -168,23 +174,27 @@ async def create_item( image_paths=image_paths, ) - # Queue AI tagging job - try: - redis = await create_pool(get_redis_settings()) + do_auto_tag = settings.effective_ai_vision_enabled and auto_tag is not False + if do_auto_tag: try: - full_image_path = f"{settings.storage_path}/{image_paths['image_path']}" - await redis.enqueue_job( - "tag_item_image", - str(item.id), - full_image_path, - _queue_name="arq:tagging", - ) - logger.info(f"Queued AI tagging job for item {item.id}") - finally: - await redis.aclose() - except Exception as e: - # Don't fail the upload if queueing fails - logger.error(f"Failed to queue AI tagging job: {e}") + redis = await create_pool(get_redis_settings()) + try: + full_image_path = f"{settings.storage_path}/{image_paths['image_path']}" + await redis.enqueue_job( + "tag_item_image", + str(item.id), + full_image_path, + _queue_name="arq:tagging", + ) + logger.info(f"Queued AI tagging job for item {item.id}") + finally: + await redis.aclose() + except Exception as e: + # Don't fail the upload if queueing fails + logger.error(f"Failed to queue AI tagging job: {e}") + else: + item = await item_service.mark_pending(item, set_ready=True) + logger.info(f"Item {item.id} left pending for external tagging") return ItemResponse.model_validate(item) @@ -214,11 +224,13 @@ async def bulk_create_items( failed = 0 # Create Redis pool once for all jobs + do_auto_tag = settings.effective_ai_vision_enabled redis = None - try: - redis = await create_pool(get_redis_settings()) - except Exception as e: - logger.error(f"Failed to connect to Redis for bulk upload: {e}") + if do_auto_tag: + try: + redis = await create_pool(get_redis_settings()) + except Exception as e: + logger.error(f"Failed to connect to Redis for bulk upload: {e}") try: for upload_file in images: @@ -276,7 +288,7 @@ async def bulk_create_items( ) # Queue AI tagging job - if redis: + if do_auto_tag and redis: try: full_image_path = f"{settings.storage_path}/{image_paths['image_path']}" await redis.enqueue_job( @@ -288,6 +300,8 @@ async def bulk_create_items( logger.info(f"Queued AI tagging for bulk item {item.id}") except Exception as e: logger.error(f"Failed to queue AI tagging for {item.id}: {e}") + elif not do_auto_tag: + item = await item_service.mark_pending(item, set_ready=True) results.append( BulkUploadResult( @@ -390,8 +404,6 @@ async def bulk_analyze_items( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> BulkAnalyzeResponse: - from app.models.item import ItemStatus - item_service = ItemService(db) queued = 0 failed = 0 @@ -422,6 +434,15 @@ async def bulk_analyze_items( continue items_to_process.append(item) + if not settings.effective_ai_vision_enabled: + for item in items_to_process: + item.status = ItemStatus.ready + item.tagging_status = TaggingStatus.pending + item.tagged_by = None + item.tagged_at = None + await db.commit() + return BulkAnalyzeResponse(queued=0, failed=failed, errors=errors) + # Set all items to processing status for item in items_to_process: item.status = ItemStatus.processing @@ -520,6 +541,15 @@ async def update_item( detail="Item not found", ) + update_data = item_data.model_dump(exclude_unset=True) + is_writeback = item.tagging_status == TaggingStatus.pending and any( + update_data.get(field) not in (None, "", [], {}) for field in TAG_WRITEBACK_FIELDS + ) + if is_writeback: + item.tagging_status = TaggingStatus.tagged + item.tagged_by = TaggedBy.manual + item.tagged_at = datetime.now(UTC) + item = await item_service.update(item, item_data) return ItemResponse.model_validate(item) @@ -792,10 +822,16 @@ async def trigger_ai_analysis( detail="Item not found", ) + if not settings.effective_ai_vision_enabled: + item.status = ItemStatus.ready + item.tagging_status = TaggingStatus.pending + item.tagged_by = None + item.tagged_at = None + await db.commit() + return {"status": "deferred", "detail": "Internal vision disabled; item marked pending"} + try: # Set item status to processing so UI shows feedback - from app.models.item import ItemStatus - item.status = ItemStatus.processing await db.commit() @@ -820,6 +856,26 @@ async def trigger_ai_analysis( ) from None +@router.post("/{item_id}/retag", response_model=ItemResponse) +async def retag_item( + item_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> ItemResponse: + """Reset an item to the pending tagging queue and clear its tagging origin.""" + item_service = ItemService(db) + item = await item_service.get_by_id(item_id, current_user.id) + + if not item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Item not found", + ) + + item = await item_service.mark_pending(item) + return ItemResponse.model_validate(item) + + @router.post("/{item_id}/rotate", response_model=ItemResponse) async def rotate_item_image( item_id: UUID, diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 2e85f645..82505475 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -33,6 +33,16 @@ class ItemStatus(enum.StrEnum): archived = "archived" +class TaggingStatus(enum.StrEnum): + pending = "pending" + tagged = "tagged" + + +class TaggedBy(enum.StrEnum): + auto = "auto" + manual = "manual" + + class ClothingItem(Base): __tablename__ = "clothing_items" @@ -69,6 +79,17 @@ class ClothingItem(Base): ai_confidence: Mapped[Decimal | None] = mapped_column(Numeric(3, 2)) ai_raw_response: Mapped[dict | None] = mapped_column(JSONB) + # Tagging lifecycle + tagging_status: Mapped[TaggingStatus] = mapped_column( + Enum(TaggingStatus, name="tagging_status", create_type=False), + default=TaggingStatus.pending, + nullable=False, + ) + tagged_by: Mapped[TaggedBy | None] = mapped_column( + Enum(TaggedBy, name="tagged_by", create_type=False) + ) + tagged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Usage tracking wear_count: Mapped[int] = mapped_column(Integer, default=0) last_worn_at: Mapped[date | None] = mapped_column(Date) diff --git a/backend/app/schemas/item.py b/backend/app/schemas/item.py index 68e8454d..9eaa9f5b 100644 --- a/backend/app/schemas/item.py +++ b/backend/app/schemas/item.py @@ -88,6 +88,9 @@ class ItemResponse(ItemBase): formality: str | None = None season: list[str] = Field(default_factory=list) status: str + tagging_status: str = "pending" + tagged_by: str | None = None + tagged_at: datetime | None = None ai_processed: bool = False ai_confidence: Decimal | None = None ai_description: str | None = None @@ -147,6 +150,7 @@ class ItemFilter(BaseModel): subtype: str | None = None colors: list[str] | None = None status: str | None = None + tagging_status: str | None = None favorite: bool | None = None needs_wash: bool | None = None is_archived: bool = False diff --git a/backend/app/services/item_service.py b/backend/app/services/item_service.py index 378fb7df..6c283714 100644 --- a/backend/app/services/item_service.py +++ b/backend/app/services/item_service.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import attributes, selectinload -from app.models.item import ClothingItem, ItemHistory, ItemStatus, WashHistory +from app.models.item import ClothingItem, ItemHistory, ItemStatus, TaggingStatus, WashHistory from app.schemas.item import DEFAULT_WASH_INTERVALS, ItemCreate, ItemFilter, ItemUpdate @@ -58,6 +58,8 @@ async def get_list( query = query.where(ClothingItem.subtype == filters.subtype) if filters.status: query = query.where(ClothingItem.status == filters.status) + if filters.tagging_status: + query = query.where(ClothingItem.tagging_status == filters.tagging_status) if filters.favorite is not None: query = query.where(ClothingItem.favorite == filters.favorite) if filters.colors: @@ -194,6 +196,17 @@ async def create( await self.db.refresh(item, ["additional_images"]) return item + # Tag attributes that also exist as first-class columns; `fit` is JSONB-only. + _TAG_COLUMN_ATTRS = ( + "colors", + "primary_color", + "pattern", + "material", + "style", + "season", + "formality", + ) + async def update(self, item: ClothingItem, item_data: ItemUpdate) -> ClothingItem: update_data = item_data.model_dump(exclude_unset=True) @@ -203,6 +216,9 @@ async def update(self, item: ClothingItem, item_data: ItemUpdate) -> ClothingIte update_data["tags"] = {k: v for k, v in tags.items() if v is not None} else: update_data["tags"] = tags.model_dump(exclude_none=True) + for attr in self._TAG_COLUMN_ATTRS: + if attr in update_data["tags"]: + setattr(item, attr, update_data["tags"][attr]) for field, value in update_data.items(): setattr(item, field, value) @@ -215,6 +231,16 @@ async def update(self, item: ClothingItem, item_data: ItemUpdate) -> ClothingIte result = await self.get_by_id(item.id, item.user_id) return result # type: ignore[return-value] + async def mark_pending(self, item: ClothingItem, *, set_ready: bool = False) -> ClothingItem: + if set_ready: + item.status = ItemStatus.ready + item.tagging_status = TaggingStatus.pending + item.tagged_by = None + item.tagged_at = None + await self.db.flush() + result = await self.get_by_id(item.id, item.user_id) + return result # type: ignore[return-value] + async def delete(self, item: ClothingItem) -> None: await self.db.delete(item) await self.db.flush() diff --git a/backend/app/workers/tagging.py b/backend/app/workers/tagging.py index 48bdad89..10fa020b 100644 --- a/backend/app/workers/tagging.py +++ b/backend/app/workers/tagging.py @@ -1,4 +1,5 @@ import logging +from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import UUID @@ -6,7 +7,7 @@ from sqlalchemy import select from app.config import get_settings -from app.models.item import ClothingItem, ItemStatus +from app.models.item import ClothingItem, ItemStatus, TaggedBy, TaggingStatus from app.services.ai_service import AIService, ClothingTags from app.workers.db import get_db_session @@ -47,6 +48,9 @@ def tags_to_item_fields(tags: ClothingTags, raw_response: str | None = None) -> "ai_confidence": tags.confidence, "ai_description": tags.description, # Human-readable description "status": ItemStatus.ready, + "tagging_status": TaggingStatus.tagged, + "tagged_by": TaggedBy.auto, + "tagged_at": datetime.now(UTC), } if raw_response: fields["ai_raw_response"] = {"raw_text": raw_response} @@ -60,6 +64,7 @@ async def mark_item_tagging_skipped(ctx: dict, item_id: str) -> None: item = result.scalar_one_or_none() if item and item.status == ItemStatus.processing: item.status = ItemStatus.ready + item.tagging_status = TaggingStatus.pending await db.commit() finally: await db.close() @@ -165,6 +170,9 @@ async def tag_item_image(ctx: dict, item_id: str, image_path: str) -> dict[str, "ai_raw_response", "tags", "ai_description", + "tagging_status", + "tagged_by", + "tagged_at", ): setattr(item, field, value) # Only update content fields if user hasn't set them (or they're default/unknown) diff --git a/backend/migrations/versions/c1a2b3d4e5f6_add_item_tagging_status.py b/backend/migrations/versions/c1a2b3d4e5f6_add_item_tagging_status.py new file mode 100644 index 00000000..92f5b412 --- /dev/null +++ b/backend/migrations/versions/c1a2b3d4e5f6_add_item_tagging_status.py @@ -0,0 +1,68 @@ +"""add_item_tagging_status + +Adds an explicit tagging lifecycle to clothing items so an external agent can own +tagging when internal vision is disabled. New native PG enums tagging_status +(pending|tagged) and tagged_by (auto|manual), plus tagging_status / tagged_by / +tagged_at columns on clothing_items. Existing rows are backfilled to tagged/auto so +they never surface in the agent's pending work-queue (back-compat: behavior unchanged). + +Revision ID: c1a2b3d4e5f6 +Revises: e1f2g3h4i5j6 +Create Date: 2026-06-19 18:50:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "c1a2b3d4e5f6" +down_revision: str | None = "e1f2g3h4i5j6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("CREATE TYPE tagging_status AS ENUM ('pending', 'tagged')") + op.execute("CREATE TYPE tagged_by AS ENUM ('auto', 'manual')") + + op.add_column( + "clothing_items", + sa.Column( + "tagging_status", + postgresql.ENUM("pending", "tagged", name="tagging_status", create_type=False), + server_default="pending", + nullable=False, + ), + ) + op.add_column( + "clothing_items", + sa.Column( + "tagged_by", + postgresql.ENUM("auto", "manual", name="tagged_by", create_type=False), + nullable=True, + ), + ) + op.add_column( + "clothing_items", + sa.Column("tagged_at", sa.DateTime(timezone=True), nullable=True), + ) + + # Back-compat: existing items predate external tagging and were tagged by the + # internal AI. Mark them tagged/auto so they don't appear as pending agent work. + op.execute( + "UPDATE clothing_items " + "SET tagging_status = 'tagged', tagged_by = 'auto', " + "tagged_at = COALESCE(updated_at, now())" + ) + + +def downgrade() -> None: + op.drop_column("clothing_items", "tagged_at") + op.drop_column("clothing_items", "tagged_by") + op.drop_column("clothing_items", "tagging_status") + op.execute("DROP TYPE IF EXISTS tagged_by") + op.execute("DROP TYPE IF EXISTS tagging_status") diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index 28af51b3..e2a464d4 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -96,7 +96,7 @@ async def test_capabilities_default_on(client: AsyncClient): data = response.json() assert data["ai"] == {"vision": True, "text": True} assert data["features"] == { - "external_tagging": False, + "external_tagging": True, "external_suggestions": False, "external_pairings": False, } diff --git a/backend/tests/test_item_tagging.py b/backend/tests/test_item_tagging.py new file mode 100644 index 00000000..29c4e8f0 --- /dev/null +++ b/backend/tests/test_item_tagging.py @@ -0,0 +1,343 @@ +"""Tests for the item tagging lifecycle: tagging_status/tagged_by/tagged_at, the +auto_tag / vision enqueue guards, the pending work-queue filter, the PATCH +write-back origin, and the retag reset. + +Covers AI-on and AI-off paths and asserts defaults preserve current behavior +(internal vision on => items are auto-tagged).""" + +from datetime import UTC, datetime +from io import BytesIO +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from PIL import Image +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import Settings +from app.models.item import ClothingItem, ItemStatus, TaggedBy, TaggingStatus +from app.services.ai_service import ClothingTags +from app.workers.tagging import tags_to_item_fields + + +def _png_bytes() -> bytes: + buf = BytesIO() + Image.new("RGB", (64, 64), (120, 80, 200)).save(buf, format="PNG") + return buf.getvalue() + + +class _FakeRedis: + """Records enqueue_job calls instead of touching a real queue.""" + + def __init__(self) -> None: + self.jobs: list[tuple] = [] + + async def enqueue_job(self, *args, **kwargs): + self.jobs.append((args, kwargs)) + + class _Job: + job_id = "test-job" + + return _Job() + + async def aclose(self) -> None: + pass + + +def _patch_redis(monkeypatch) -> _FakeRedis: + fake = _FakeRedis() + + async def _create_pool(*_args, **_kwargs): + return fake + + monkeypatch.setattr("app.api.items.create_pool", _create_pool) + return fake + + +# --- Schema defaults & worker origin ---------------------------------------- + + +@pytest.mark.asyncio +async def test_new_item_defaults_to_pending(test_user, db_session: AsyncSession): + item = ClothingItem(user_id=test_user.id, type="shirt", image_path="test/x.jpg") + db_session.add(item) + await db_session.commit() + await db_session.refresh(item) + assert item.tagging_status == TaggingStatus.pending + assert item.tagged_by is None + assert item.tagged_at is None + + +def test_auto_tag_records_auto_origin(): + fields = tags_to_item_fields(ClothingTags(type="shirt")) + assert fields["tagging_status"] == TaggingStatus.tagged + assert fields["tagged_by"] == TaggedBy.auto + assert isinstance(fields["tagged_at"], datetime) + + +# --- create_item: auto_tag / vision enqueue guard --------------------------- + + +@pytest.mark.asyncio +async def test_create_enqueues_when_vision_on(client: AsyncClient, auth_headers, monkeypatch): + fake = _patch_redis(monkeypatch) + resp = await client.post( + "/api/v1/items", + headers=auth_headers, + files={"image": ("x.png", _png_bytes(), "image/png")}, + data={"type": "shirt"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["status"] == "processing" + assert body["tagging_status"] == "pending" + assert len(fake.jobs) == 1 + + +@pytest.mark.asyncio +async def test_create_with_auto_tag_false_leaves_pending( + client: AsyncClient, auth_headers, monkeypatch +): + fake = _patch_redis(monkeypatch) + resp = await client.post( + "/api/v1/items", + headers=auth_headers, + files={"image": ("x.png", _png_bytes(), "image/png")}, + data={"type": "shirt", "auto_tag": "false"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["status"] == "ready" + assert body["tagging_status"] == "pending" + assert fake.jobs == [] + + +@pytest.mark.asyncio +async def test_create_leaves_pending_when_vision_disabled( + client: AsyncClient, auth_headers, monkeypatch +): + fake = _patch_redis(monkeypatch) + monkeypatch.setattr("app.api.items.settings", Settings(ai_internal_enabled=False)) + resp = await client.post( + "/api/v1/items", + headers=auth_headers, + files={"image": ("x.png", _png_bytes(), "image/png")}, + data={"type": "shirt"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["status"] == "ready" + assert body["tagging_status"] == "pending" + assert fake.jobs == [] + + +# --- Pending work-queue filter ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_by_tagging_status_pending( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + pending = ClothingItem( + user_id=test_user.id, + type="shirt", + image_path="test/p.jpg", + status=ItemStatus.ready, + tagging_status=TaggingStatus.pending, + ) + tagged = ClothingItem( + user_id=test_user.id, + type="pants", + image_path="test/t.jpg", + status=ItemStatus.ready, + tagging_status=TaggingStatus.tagged, + tagged_by=TaggedBy.auto, + ) + db_session.add_all([pending, tagged]) + await db_session.commit() + + resp = await client.get( + "/api/v1/items", params={"tagging_status": "pending"}, headers=auth_headers + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["type"] == "shirt" + assert data["items"][0]["tagging_status"] == "pending" + + +# --- PATCH write-back marks a pending item tagged with a server-derived origin --- + + +async def _make_pending_item(test_user, db_session, type_="unknown") -> ClothingItem: + item = ClothingItem( + user_id=test_user.id, + type=type_, + image_path=f"test/{uuid4()}.jpg", + status=ItemStatus.ready, + tagging_status=TaggingStatus.pending, + ) + db_session.add(item) + await db_session.commit() + return item + + +@pytest.mark.asyncio +async def test_patch_writeback_marks_tagged_by_manual( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = await _make_pending_item(test_user, db_session) + resp = await client.patch( + f"/api/v1/items/{item.id}", + json={"type": "shirt", "primary_color": "blue"}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["tagging_status"] == "tagged" + assert body["tagged_by"] == "manual" + assert body["tagged_at"] is not None + + +@pytest.mark.asyncio +async def test_patch_non_tag_field_does_not_mark_tagged( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = await _make_pending_item(test_user, db_session, type_="shirt") + resp = await client.patch( + f"/api/v1/items/{item.id}", json={"favorite": True}, headers=auth_headers + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["favorite"] is True + assert body["tagging_status"] == "pending" + assert body["tagged_by"] is None + + +@pytest.mark.asyncio +async def test_patch_with_empty_tag_values_does_not_mark_tagged( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = await _make_pending_item(test_user, db_session, type_="shirt") + resp = await client.patch( + f"/api/v1/items/{item.id}", + json={"colors": [], "primary_color": None}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["tagging_status"] == "pending" + assert body["tagged_by"] is None + assert body["tagged_at"] is None + + +@pytest.mark.asyncio +async def test_patch_does_not_rewrite_existing_origin( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = ClothingItem( + user_id=test_user.id, + type="shirt", + image_path="test/tagged.jpg", + status=ItemStatus.ready, + tagging_status=TaggingStatus.tagged, + tagged_by=TaggedBy.auto, + tagged_at=datetime.now(UTC), + ) + db_session.add(item) + await db_session.commit() + + resp = await client.patch( + f"/api/v1/items/{item.id}", json={"type": "jacket"}, headers=auth_headers + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["type"] == "jacket" + assert body["tagging_status"] == "tagged" + assert body["tagged_by"] == "auto" # origin preserved, not rewritten to manual + + +@pytest.mark.asyncio +async def test_tagged_by_cannot_be_forged_via_body( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = await _make_pending_item(test_user, db_session) + resp = await client.patch( + f"/api/v1/items/{item.id}", + json={"type": "shirt", "tagged_by": "auto", "tagging_status": "tagged"}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + # Origin is server-derived (manual = supplied via the API), ignoring the body fields. + assert resp.json()["tagged_by"] == "manual" + + +# --- retag resets to the pending queue -------------------------------------- + + +@pytest.mark.asyncio +async def test_retag_resets_to_pending( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = ClothingItem( + user_id=test_user.id, + type="shirt", + image_path="test/r.jpg", + status=ItemStatus.ready, + tagging_status=TaggingStatus.tagged, + tagged_by=TaggedBy.auto, + tagged_at=datetime.now(UTC), + ) + db_session.add(item) + await db_session.commit() + + resp = await client.post(f"/api/v1/items/{item.id}/retag", headers=auth_headers) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["tagging_status"] == "pending" + assert body["tagged_by"] is None + assert body["tagged_at"] is None + + +@pytest.mark.asyncio +async def test_retag_unknown_item_404(client: AsyncClient, auth_headers): + resp = await client.post(f"/api/v1/items/{uuid4()}/retag", headers=auth_headers) + assert resp.status_code == 404 + + +# --- Tag write-back projects onto first-class columns (not just the JSONB) ---- + + +@pytest.mark.asyncio +async def test_patch_tags_projects_to_columns( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + item = await _make_pending_item(test_user, db_session) + resp = await client.patch( + f"/api/v1/items/{item.id}", + json={ + "tags": { + "pattern": "solid", + "material": "linen", + "style": ["casual"], + "season": ["summer"], + "formality": "casual", + "colors": ["blue", "white"], + "primary_color": "blue", + } + }, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + # Columns projected from the tags block. + assert body["pattern"] == "solid" + assert body["material"] == "linen" + assert body["style"] == ["casual"] + assert body["season"] == ["summer"] + assert body["formality"] == "casual" + assert body["colors"] == ["blue", "white"] + assert body["primary_color"] == "blue" + # JSONB carries them too, and it counts as a tag write-back. + assert body["tags"]["pattern"] == "solid" + assert body["tagging_status"] == "tagged"