fix(pilot): persist Apply — stamp applied_at on click
Issue #2 from phase-8-review-issues.md. Apply was client-side-only via a bannerApplied flag. Refresh / chat reselect / multi-tab would drop Verifying state back to Proposed. - New POST /ai-sessions/{sid}/suggested-fixes/{fid}/apply stamps applied_at without changing status (still 'proposed'). Idempotent if already stamped; 409 if fix is past proposed (a terminal outcome was already recorded). - Bumps state_version so resolve/escalate preview bundles reflect that the fix has entered verifying. - Frontend handleApplyFix calls the endpoint and uses the returned applied_at directly. bannerApplied client flag is removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -327,3 +327,113 @@ async def test_resolution_note_preview_reflects_outcome_after_patch(
|
||||
assert distinct_failure_reason in bundle_b, (
|
||||
"Bundle for second preview should include the failure_reason text"
|
||||
)
|
||||
|
||||
|
||||
# ── Apply endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_stamps_applied_at(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply stamps applied_at and bumps state_version."""
|
||||
from uuid import UUID
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AISession).where(AISession.id == UUID(session_id))
|
||||
)
|
||||
session_obj = result.scalar_one()
|
||||
initial_version = session_obj.state_version
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["applied_at"] is not None, "applied_at must be set after /apply"
|
||||
assert body["status"] == "proposed", "status must remain 'proposed' after /apply"
|
||||
|
||||
await test_db.refresh(session_obj)
|
||||
assert session_obj.state_version == initial_version + 1, (
|
||||
"/apply must bump state_version so preview cache is invalidated"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_is_idempotent(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
|
||||
from uuid import UUID
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
applied_at_first = r1.json()["applied_at"]
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AISession).where(AISession.id == UUID(session_id))
|
||||
)
|
||||
session_obj = result.scalar_one()
|
||||
version_after_first = session_obj.state_version
|
||||
|
||||
r2 = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["applied_at"] == applied_at_first, (
|
||||
"applied_at must not change on second /apply call"
|
||||
)
|
||||
|
||||
await test_db.refresh(session_obj)
|
||||
assert session_obj.state_version == version_after_first, (
|
||||
"state_version must not be bumped a second time on idempotent /apply"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_rejects_non_proposed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply returns 409 when fix status is 'applied_success'."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
# Advance the fix to a terminal status via the outcome endpoint.
|
||||
r_outcome = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r_outcome.status_code == 200
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_rejects_dismissed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply returns 409 when fix status is 'dismissed'."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r_outcome = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "dismissed"},
|
||||
)
|
||||
assert r_outcome.status_code == 200
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
Reference in New Issue
Block a user