chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Integration tests for opt-in git worktree cleanup on session delete.
|
||||
|
||||
Drives ``DELETE /v1/sessions/{id}`` through the full app with a fake
|
||||
host registered in ``app.state.host_registry``. Verifies the
|
||||
``?delete_branch`` flag gates whether a ``host.remove_worktree`` frame
|
||||
is sent, and that the stored worktree path + branch (not request input)
|
||||
are used. See designs/SESSION_GIT_WORKTREE.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.host.frames import (
|
||||
HostHelloFrame,
|
||||
HostRemoveWorktreeFrame,
|
||||
decode_host_frame,
|
||||
)
|
||||
from omnigent.server.auth import RESERVED_USER_LOCAL
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.host_store import HostStore
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_HOST_ID = "host_wt_delete"
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
"""Minimal WebSocket stand-in (the registry only enqueues)."""
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
"""No-op send — frames flow through the outbound queue.
|
||||
|
||||
:param data: JSON-encoded frame text (ignored).
|
||||
"""
|
||||
|
||||
|
||||
async def _register_fake_host(
|
||||
app: FastAPI,
|
||||
db_uri: str,
|
||||
) -> list[HostRemoveWorktreeFrame]:
|
||||
"""Register a fake host and start a drain that captures remove frames.
|
||||
|
||||
:param app: The app whose ``host_registry`` to register into.
|
||||
:param db_uri: DB URI so the host row (FK target) can be upserted.
|
||||
:returns: A list that accumulates every ``HostRemoveWorktreeFrame``
|
||||
the server sends to this host.
|
||||
"""
|
||||
# Upsert the host row so the conversation's host_id FK resolves.
|
||||
HostStore(db_uri).upsert_on_connect(_HOST_ID, "wt-host", RESERVED_USER_LOCAL)
|
||||
registry = app.state.host_registry
|
||||
conn = registry.register(
|
||||
host_id=_HOST_ID,
|
||||
ws=_FakeWebSocket(), # type: ignore[arg-type] — duck-typed
|
||||
hello=HostHelloFrame(version="0.1.0-test", frame_protocol_version=1, name="wt-host"),
|
||||
owner=RESERVED_USER_LOCAL,
|
||||
)
|
||||
captured: list[HostRemoveWorktreeFrame] = []
|
||||
|
||||
async def _drain() -> None:
|
||||
"""Capture remove-worktree frames and reply ok."""
|
||||
while True:
|
||||
frame_text = await conn.outbound_queue.get()
|
||||
if frame_text is None:
|
||||
return
|
||||
frame = decode_host_frame(frame_text)
|
||||
if isinstance(frame, HostRemoveWorktreeFrame):
|
||||
captured.append(frame)
|
||||
fut = conn.pending_remove_worktrees.pop(frame.request_id, None)
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result({"status": "ok", "error": None})
|
||||
|
||||
task = asyncio.create_task(_drain())
|
||||
# Stash so the caller can stop the drain on teardown.
|
||||
conn._drain_task_for_test = task # type: ignore[attr-defined]
|
||||
return captured
|
||||
|
||||
|
||||
def _make_worktree_conversation(db_uri: str) -> str:
|
||||
"""Create a session row that looks like a server-created worktree.
|
||||
|
||||
:param db_uri: DB URI for the conversation store.
|
||||
:returns: The new conversation id.
|
||||
"""
|
||||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||||
conv = conv_store.create_conversation(
|
||||
agent_id=None,
|
||||
host_id=_HOST_ID,
|
||||
workspace="/Users/alice/myrepo-worktrees/feature-login",
|
||||
git_branch="feature/login",
|
||||
)
|
||||
return conv.id
|
||||
|
||||
|
||||
async def test_delete_with_flag_sends_remove_worktree(
|
||||
app: FastAPI,
|
||||
client: httpx.AsyncClient,
|
||||
db_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
``?delete_branch=true`` on a worktree session sends a
|
||||
host.remove_worktree frame carrying the stored path + branch.
|
||||
|
||||
If no frame is captured, the delete-flow gate or the proxy call
|
||||
is broken and the user's checkbox would silently do nothing. The
|
||||
path/branch assertions prove the server uses the *stored* values
|
||||
(not request input), which is the multi-user-safe contract.
|
||||
"""
|
||||
captured = await _register_fake_host(app, db_uri)
|
||||
conv_id = _make_worktree_conversation(db_uri)
|
||||
|
||||
resp = await client.delete(f"/v1/sessions/{conv_id}?delete_branch=true")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Exactly one remove frame, with the stored worktree path/branch
|
||||
# and delete_branch=True (the box was checked).
|
||||
assert len(captured) == 1, (
|
||||
f"Expected exactly one host.remove_worktree frame, got {len(captured)}. "
|
||||
"0 means the delete-flow cleanup gate didn't fire; >1 means it fired twice."
|
||||
)
|
||||
frame = captured[0]
|
||||
assert frame.worktree_path == "/Users/alice/myrepo-worktrees/feature-login"
|
||||
assert frame.branch == "feature/login"
|
||||
assert frame.delete_branch is True
|
||||
|
||||
|
||||
async def test_delete_without_flag_sends_no_remove_worktree(
|
||||
app: FastAPI,
|
||||
client: httpx.AsyncClient,
|
||||
db_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
Deleting a worktree session WITHOUT the flag leaves the worktree
|
||||
alone — no host.remove_worktree frame is sent.
|
||||
|
||||
If a frame is captured here, the cleanup is happening
|
||||
unconditionally and would destroy worktrees/branches the user
|
||||
never asked to remove.
|
||||
"""
|
||||
captured = await _register_fake_host(app, db_uri)
|
||||
conv_id = _make_worktree_conversation(db_uri)
|
||||
|
||||
resp = await client.delete(f"/v1/sessions/{conv_id}")
|
||||
assert resp.status_code == 200
|
||||
# Default is delete_branch=false → no cleanup.
|
||||
assert captured == []
|
||||
|
||||
|
||||
async def test_delete_non_worktree_session_ignores_flag(
|
||||
app: FastAPI,
|
||||
client: httpx.AsyncClient,
|
||||
db_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
``?delete_branch=true`` on a session with no worktree
|
||||
(``git_branch`` NULL) is a no-op — no remove frame.
|
||||
|
||||
The gate keys off ``git_branch IS NOT NULL``; without that check a
|
||||
plain session delete would try to remove a worktree that doesn't
|
||||
exist.
|
||||
"""
|
||||
captured = await _register_fake_host(app, db_uri)
|
||||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||||
# Plain session: no host, no workspace, no git_branch.
|
||||
conv = conv_store.create_conversation(agent_id=None)
|
||||
|
||||
resp = await client.delete(f"/v1/sessions/{conv.id}?delete_branch=true")
|
||||
assert resp.status_code == 200
|
||||
assert captured == []
|
||||
Reference in New Issue
Block a user