414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""End-to-end style integration tests for the comments REST API.
|
|
|
|
Covers gaps not exercised by ``test_comments_routes.py``:
|
|
|
|
* Full CRUD lifecycle in a single test (create, list, get-by-filter,
|
|
update body+status, delete, verify gone).
|
|
* ``POST /v1/sessions/{id}/comments/send`` — formatted message
|
|
includes per-file grouping, anchor content, and offset range.
|
|
* ``GET /v1/sessions/{id}/comments?path=…`` path filtering.
|
|
* 404 on delete of nonexistent comment.
|
|
* 404 on comment operations against a nonexistent session (auth mode).
|
|
* PATCH with both body and status in one request.
|
|
* ``GET /v1/sessions`` includes ``comments_count`` and
|
|
``comments_updated_at`` from the comments fingerprint.
|
|
|
|
Uses the same ``auth_app`` / ``auth_client`` fixture pattern as the
|
|
existing tests (real ``SqlAlchemyPermissionStore`` so
|
|
``UnifiedAuthProvider`` is active).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.server.app import create_app
|
|
from omnigent.server.auth import LEVEL_EDIT
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
from tests.server.conftest import ControllableMockClient
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
ALICE = "alice@example.com"
|
|
|
|
|
|
def _seed_session(db_uri: str, *, with_agent: bool = False) -> str:
|
|
"""Create a conversation and grant Alice edit access.
|
|
|
|
:param db_uri: Per-test SQLite URI.
|
|
:param with_agent: If ``True``, create an agent row so the session
|
|
appears in ``GET /v1/sessions`` (which filters ``agent_id IS NOT NULL``).
|
|
:returns: The session id.
|
|
"""
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
agent_id: str | None = None
|
|
if with_agent:
|
|
agent_store = SqlAlchemyAgentStore(db_uri)
|
|
agent = agent_store.create(
|
|
agent_id="ag_test",
|
|
name="test-agent",
|
|
bundle_location="fake/bundle",
|
|
)
|
|
agent_id = agent.id
|
|
conv = conv_store.create_conversation(agent_id=agent_id)
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
perm_store.ensure_user(ALICE)
|
|
perm_store.grant(ALICE, conv.id, LEVEL_EDIT)
|
|
return conv.id
|
|
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture()
|
|
def auth_app(
|
|
runtime_init: None,
|
|
db_uri: str,
|
|
tmp_path: Path,
|
|
) -> FastAPI:
|
|
"""App with permission store enabled (auth active)."""
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=tmp_path / "cache",
|
|
),
|
|
comment_store=SqlAlchemyCommentStore(db_uri),
|
|
permission_store=SqlAlchemyPermissionStore(db_uri),
|
|
auth_provider=UnifiedAuthProvider(source="header"),
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def auth_client(
|
|
auth_app: FastAPI,
|
|
mock_llm: ControllableMockClient,
|
|
tmp_path: Path,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""Async HTTP client wired to the auth-enabled app."""
|
|
from omnigent.runtime import set_harness_process_manager
|
|
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
|
|
|
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
|
|
await pm.start()
|
|
set_harness_process_manager(pm)
|
|
|
|
transport = httpx.ASGITransport(app=auth_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
mock_llm.release_all()
|
|
set_harness_process_manager(None)
|
|
await pm.shutdown()
|
|
|
|
|
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_full_crud_lifecycle(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Create -> list -> filter -> update body+status -> delete -> verify gone."""
|
|
session_id = _seed_session(db_uri)
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
# ── Create two comments on different paths ───────────────────────
|
|
c1_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/foo.py",
|
|
"body": "Fix this function",
|
|
"start_index": 0,
|
|
"end_index": 20,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c1_resp.status_code == 200, c1_resp.text
|
|
c1 = c1_resp.json()
|
|
assert c1["status"] == "draft"
|
|
assert c1["path"] == "src/foo.py"
|
|
|
|
c2_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/bar.py",
|
|
"body": "Add a type hint",
|
|
"start_index": 5,
|
|
"end_index": 15,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c2_resp.status_code == 200, c2_resp.text
|
|
c2 = c2_resp.json()
|
|
|
|
# ── List all ─────────────────────────────────────────────────────
|
|
list_resp = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
headers=headers,
|
|
)
|
|
assert list_resp.status_code == 200
|
|
assert len(list_resp.json()) == 2
|
|
|
|
# ── Filter by path ───────────────────────────────────────────────
|
|
filtered = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
params={"path": "src/foo.py"},
|
|
headers=headers,
|
|
)
|
|
assert filtered.status_code == 200
|
|
filtered_comments = filtered.json()
|
|
assert len(filtered_comments) == 1
|
|
assert filtered_comments[0]["id"] == c1["id"]
|
|
|
|
# ── Update both body and status in one PATCH ─────────────────────
|
|
patch_resp = await auth_client.patch(
|
|
f"/v1/sessions/{session_id}/comments/{c1['id']}",
|
|
json={"body": "Updated function comment", "status": "addressed"},
|
|
headers=headers,
|
|
)
|
|
assert patch_resp.status_code == 200
|
|
patched = patch_resp.json()
|
|
assert patched["body"] == "Updated function comment"
|
|
assert patched["status"] == "addressed"
|
|
|
|
# ── Delete ───────────────────────────────────────────────────────
|
|
del_resp = await auth_client.delete(
|
|
f"/v1/sessions/{session_id}/comments/{c1['id']}",
|
|
headers=headers,
|
|
)
|
|
assert del_resp.status_code == 200
|
|
assert del_resp.json()["deleted"] is True
|
|
|
|
# ── Verify deleted ───────────────────────────────────────────────
|
|
remaining = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
headers=headers,
|
|
)
|
|
assert remaining.status_code == 200
|
|
ids = [c["id"] for c in remaining.json()]
|
|
assert c1["id"] not in ids
|
|
assert c2["id"] in ids
|
|
|
|
|
|
async def test_delete_nonexistent_comment_returns_404(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Deleting a comment that does not exist returns 404."""
|
|
session_id = _seed_session(db_uri)
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
resp = await auth_client.delete(
|
|
f"/v1/sessions/{session_id}/comments/nonexistent-id",
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_comment_on_nonexistent_session_returns_404(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Operations on a session the user has no grant for return 403/404."""
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
resp = await auth_client.post(
|
|
"/v1/sessions/conv_does_not_exist/comments",
|
|
json={
|
|
"path": "src/foo.py",
|
|
"body": "Hello",
|
|
"start_index": 0,
|
|
"end_index": 5,
|
|
},
|
|
headers=headers,
|
|
)
|
|
# The permission check fires before the comment store — the user
|
|
# has no grant for this session, so they get a 404.
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_send_formats_multifile_message_with_anchors(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""The send endpoint groups by file, includes anchors and offsets."""
|
|
session_id = _seed_session(db_uri)
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
# Comment with anchor_content on file A
|
|
c1_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/alpha.py",
|
|
"body": "Rename variable",
|
|
"start_index": 10,
|
|
"end_index": 25,
|
|
"anchor_content": "old_var_name",
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c1_resp.status_code == 200, c1_resp.text
|
|
c1 = c1_resp.json()
|
|
|
|
# Comment without anchor_content on file B
|
|
c2_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/beta.py",
|
|
"body": "Add docstring",
|
|
"start_index": 0,
|
|
"end_index": 8,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c2_resp.status_code == 200, c2_resp.text
|
|
c2 = c2_resp.json()
|
|
|
|
# Comment on file A again (should group with the first)
|
|
c3_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/alpha.py",
|
|
"body": "Fix indentation",
|
|
"start_index": 50,
|
|
"end_index": 60,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c3_resp.status_code == 200, c3_resp.text
|
|
c3 = c3_resp.json()
|
|
|
|
send_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments/send",
|
|
json={"comment_ids": [c1["id"], c2["id"], c3["id"]]},
|
|
headers=headers,
|
|
)
|
|
assert send_resp.status_code == 200
|
|
payload = send_resp.json()
|
|
|
|
msg = payload["formatted_message"]
|
|
|
|
# File grouping: alpha.py comes before beta.py alphabetically
|
|
alpha_pos = msg.index("src/alpha.py")
|
|
beta_pos = msg.index("src/beta.py")
|
|
assert alpha_pos < beta_pos, "Comments should be grouped alphabetically by file path"
|
|
|
|
# Anchor content appears quoted in the message
|
|
assert '"old_var_name"' in msg, "Anchor content should appear quoted in the message"
|
|
|
|
# Both bodies present
|
|
assert "Rename variable" in msg
|
|
assert "Add docstring" in msg
|
|
assert "Fix indentation" in msg
|
|
|
|
# Offset ranges present
|
|
assert "10" in msg and "25" in msg, "Offset range for c1 should appear"
|
|
|
|
# All comment ids echoed
|
|
assert set(payload["sent_comment_ids"]) == {c1["id"], c2["id"], c3["id"]}
|
|
|
|
# All three comments are now addressed
|
|
list_resp = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
headers=headers,
|
|
)
|
|
statuses = {c["id"]: c["status"] for c in list_resp.json()}
|
|
for cid in [c1["id"], c2["id"], c3["id"]]:
|
|
assert statuses[cid] == "addressed"
|
|
|
|
|
|
async def test_send_nonexistent_comment_returns_404(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Sending a comment id that doesn't exist returns 404."""
|
|
session_id = _seed_session(db_uri)
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments/send",
|
|
json={"comment_ids": ["nonexistent-id"]},
|
|
headers=headers,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_session_list_includes_comments_fingerprint(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""GET /v1/sessions includes comments_count and comments_updated_at."""
|
|
session_id = _seed_session(db_uri, with_agent=True)
|
|
headers = {"X-Forwarded-Email": ALICE}
|
|
|
|
# Before any comments: fingerprint should show zero
|
|
list_resp = await auth_client.get("/v1/sessions", headers=headers)
|
|
assert list_resp.status_code == 200
|
|
items = list_resp.json()["data"]
|
|
target = [s for s in items if s["id"] == session_id]
|
|
assert len(target) == 1
|
|
assert target[0]["comments_count"] == 0
|
|
assert target[0].get("comments_updated_at") is None
|
|
|
|
# Add a comment
|
|
add_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/main.py",
|
|
"body": "Check this",
|
|
"start_index": 0,
|
|
"end_index": 10,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert add_resp.status_code == 200
|
|
comment = add_resp.json()
|
|
|
|
# After adding: count=1 and updated_at is set
|
|
list_resp2 = await auth_client.get("/v1/sessions", headers=headers)
|
|
items2 = list_resp2.json()["data"]
|
|
target2 = [s for s in items2 if s["id"] == session_id]
|
|
assert len(target2) == 1
|
|
assert target2[0]["comments_count"] == 1
|
|
assert target2[0]["comments_updated_at"] is not None
|
|
assert target2[0]["comments_updated_at"] == comment["updated_at"]
|
|
|
|
# Add a second comment and verify count bumps
|
|
add_resp2 = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": "src/main.py",
|
|
"body": "And this too",
|
|
"start_index": 20,
|
|
"end_index": 30,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert add_resp2.status_code == 200, add_resp2.text
|
|
list_resp3 = await auth_client.get("/v1/sessions", headers=headers)
|
|
items3 = list_resp3.json()["data"]
|
|
target3 = [s for s in items3 if s["id"] == session_id]
|
|
assert target3[0]["comments_count"] == 2
|