"""Regression tests for re-respond ("修改回应") on the /me/inventory flow. The /me/inventory UI exposes a "修改回应" button on already-responded items and prefills the prior values into the same RespondModal. Before this fix the backend rejected every non-pending item with a 409 "first response wins", causing a user-reported bug where flipping issue_reported → in_use returned an opaque error. These tests pin the policy: * issue_reported → in_use (confirming) is allowed and flips counters * confirmed → in_use is allowed (idempotent edit) * admin-resolved issues stay frozen (409) * prior 'transferred' responses are frozen (409) because the asset row has already moved and the asset transfer flow must own the undo """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ConflictError from app.domains.asset.inventory_service import AssetInventoryService from app.domains.asset.models import ( AssetInventoryCycle, AssetInventoryItem, AssetItem, ) from app.identity.person_models import IdentityPerson async def _make_person(db: AsyncSession, email: str) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db.add(p) await db.commit() return p async def _make_cycle_with_item( db: AsyncSession, holder: IdentityPerson, ) -> tuple[AssetInventoryCycle, AssetInventoryItem, AssetItem]: asset = AssetItem( asset_tag=f"INV-{uuid.uuid4().hex[:6]}", category="laptop", status="claimed", location="北京", assigned_to=holder.id, ) db.add(asset) await db.flush() cycle = AssetInventoryCycle( id=uuid.uuid4(), name="re-respond test", status="active", started_at=datetime.now(UTC), total_items=1, pending_count=1, ) db.add(cycle) await db.flush() item = AssetInventoryItem( id=uuid.uuid4(), cycle_id=cycle.id, asset_id=asset.id, holder_kind="assignee", holder_person_id=holder.id, asset_tag_snapshot=asset.asset_tag, asset_category_snapshot=asset.category, asset_status_snapshot=asset.status, status="pending", ) db.add(item) await db.commit() return cycle, item, asset @pytest.mark.asyncio async def test_issue_reported_can_be_modified_to_in_use( db_session: AsyncSession, ): """The user-reported bug: mark an item with an issue, then change the response to in_use. Used to return a 409 'first response wins'. Now succeeds and flips counters.""" holder = await _make_person(db_session, "modify-1@test.com") cycle, item, _asset = await _make_cycle_with_item(db_session, holder) svc = AssetInventoryService(db_session) await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="damaged", response_notes="screen cracked", ) await db_session.commit() await db_session.refresh(item) await db_session.refresh(cycle) assert item.status == "issue_reported" assert cycle.issue_count == 1 assert cycle.confirmed_count == 0 # Re-respond — was 409 before this fix. await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="in_use", response_notes="actually fine, false alarm", ) await db_session.commit() await db_session.refresh(item) await db_session.refresh(cycle) assert item.status == "confirmed" assert item.response_type == "in_use" assert item.issue_status is None assert cycle.confirmed_count == 1 assert cycle.issue_count == 0 assert cycle.pending_count == 0 @pytest.mark.asyncio async def test_confirmed_can_be_modified(db_session: AsyncSession): """Once confirmed, the holder can still flip to an issue response (the inverse of the user-reported case).""" holder = await _make_person(db_session, "modify-2@test.com") _cycle, item, _asset = await _make_cycle_with_item(db_session, holder) svc = AssetInventoryService(db_session) await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="in_use", ) await db_session.commit() await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="damaged", response_notes="actually broken", ) await db_session.commit() await db_session.refresh(item) assert item.status == "issue_reported" assert item.response_type == "damaged" assert item.issue_status == "open" @pytest.mark.asyncio async def test_resolved_issue_cannot_be_modified(db_session: AsyncSession): """Once an admin has resolved the issue, the holder can no longer re-write the response — the admin's downstream action has already landed and a silent edit would lose audit clarity.""" holder = await _make_person(db_session, "modify-3@test.com") admin = await _make_person(db_session, "admin-3@test.com") _cycle, item, _asset = await _make_cycle_with_item(db_session, holder) svc = AssetInventoryService(db_session) await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="damaged", ) await db_session.commit() await svc.resolve_issue( item.id, actor_person_id=admin.id, notes="ticket filed", ) await db_session.commit() with pytest.raises(ConflictError, match="resolved"): await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="in_use", ) @pytest.mark.asyncio async def test_legacy_transferred_response_cannot_be_modified( db_session: AsyncSession, ): """``transferred`` is no longer an accepted inventory response, but LEGACY items recorded before it was removed still carry ``response_type='transferred'`` — and their asset row already moved (AssetOperation + AssetHistory emitted at the time). Editing such a row belongs to the asset transfer flow, not /respond, so re-respond stays frozen with a directive 409. Seed the legacy row directly since the service can no longer create one.""" holder = await _make_person(db_session, "modify-4@test.com") transferee = await _make_person(db_session, "transferee-4@test.com") _cycle, item, _asset = await _make_cycle_with_item(db_session, holder) # Simulate a historical transferred response. item.status = "issue_reported" item.response_type = "transferred" item.transferred_to_id = transferee.id item.responded_by = holder.id item.responded_at = datetime.now(UTC) item.issue_status = "open" await db_session.commit() svc = AssetInventoryService(db_session) with pytest.raises(ConflictError, match="transferred"): await svc.respond_to_item( item.id, actor_person_id=holder.id, is_admin=False, response_type="in_use", )