fix(backend/copilot): add route tests, mutual-exclusive cursor validation, newestSequence guard

- routes_test.py: 4 new tests for get_session — forward_paginated=True for
  completed sessions, False for active, after_sequence wiring, 400 for
  conflicting cursors
- routes.py: reject 400 when both before_sequence and after_sequence are sent
- useLoadMoreMessages.ts: guard newestSequence in else-branch so a parent
  refetch never reverts a cursor already advanced by paging
This commit is contained in:
Zamil Majdy
2026-04-15 20:13:39 +07:00
parent e7b621f0b0
commit e85c042eb6
3 changed files with 120 additions and 1 deletions

View File

@@ -479,6 +479,12 @@ async def get_session(
SessionDetailResponse: Details for the requested session, including
active_stream info and pagination metadata.
"""
if before_sequence is not None and after_sequence is not None:
raise HTTPException(
status_code=400,
detail="before_sequence and after_sequence are mutually exclusive",
)
is_initial_load = before_sequence is None and after_sequence is None
# Check active stream before the DB query on initial loads so we can

View File

@@ -722,3 +722,110 @@ def test_disconnect_stream_returns_404_when_session_missing(
assert response.status_code == 404
mock_disconnect.assert_not_awaited()
# ─── GET /sessions/{session_id} — forward/backward pagination ──────────────────
def _make_paginated_messages(
mocker: pytest_mock.MockerFixture, *, has_more: bool = False
):
"""Return a mock PaginatedMessages and configure the DB patch."""
from datetime import UTC, datetime
from backend.copilot.db import PaginatedMessages
from backend.copilot.model import ChatMessage, ChatSessionInfo, ChatSessionMetadata
now = datetime.now(UTC)
session_info = ChatSessionInfo(
session_id="sess-1",
user_id=TEST_USER_ID,
usage=[],
started_at=now,
updated_at=now,
metadata=ChatSessionMetadata(),
)
page = PaginatedMessages(
messages=[ChatMessage(role="user", content="hello", sequence=0)],
has_more=has_more,
oldest_sequence=0,
newest_sequence=0,
session=session_info,
)
mock_paginate = mocker.patch(
"backend.api.features.chat.routes.get_chat_messages_paginated",
new_callable=AsyncMock,
return_value=page,
)
return page, mock_paginate
def test_get_session_completed_returns_forward_paginated(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""Completed sessions (no active stream) return forward_paginated=True."""
_make_paginated_messages(mocker)
mocker.patch(
"backend.api.features.chat.routes.stream_registry.get_active_session",
new_callable=AsyncMock,
return_value=(None, None),
)
response = client.get("/sessions/sess-1")
assert response.status_code == 200
data = response.json()
assert data["forward_paginated"] is True
assert data["newest_sequence"] == 0
def test_get_session_active_returns_backward_paginated(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""Active sessions (with running stream) return forward_paginated=False."""
from backend.copilot.stream_registry import ActiveSession
_make_paginated_messages(mocker)
active = MagicMock(spec=ActiveSession)
active.turn_id = "turn-1"
mocker.patch(
"backend.api.features.chat.routes.stream_registry.get_active_session",
new_callable=AsyncMock,
return_value=(active, "msg-1"),
)
response = client.get("/sessions/sess-1")
assert response.status_code == 200
data = response.json()
assert data["forward_paginated"] is False
assert data["active_stream"] is not None
assert data["active_stream"]["turn_id"] == "turn-1"
def test_get_session_after_sequence_returns_forward_paginated(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
"""after_sequence param returns forward_paginated=True; no stream check needed."""
_, mock_paginate = _make_paginated_messages(mocker)
response = client.get("/sessions/sess-1?after_sequence=10")
assert response.status_code == 200
data = response.json()
assert data["forward_paginated"] is True
call_kwargs = mock_paginate.call_args
assert call_kwargs.kwargs.get("after_sequence") == 10
assert call_kwargs.kwargs.get("before_sequence") is None
def test_get_session_both_cursors_returns_400(
test_user_id: str,
) -> None:
"""Sending both before_sequence and after_sequence returns 400."""
response = client.get("/sessions/sess-1?before_sequence=5&after_sequence=10")
assert response.status_code == 400

View File

@@ -83,7 +83,13 @@ export function useLoadMoreMessages({
// Update from parent when initial data changes (e.g. refetch)
prevInitialOldestRef.current = initialOldestSequence;
setOldestSequence(initialOldestSequence);
setNewestSequence(initialNewestSequence);
// Only regress the forward cursor if we haven't paged ahead yet —
// otherwise a parent refetch would reset a cursor we already advanced.
setNewestSequence((prev) =>
prev !== null && prev > (initialNewestSequence ?? -1)
? prev
: initialNewestSequence,
);
setHasMore(initialHasMore);
}
}, [sessionId, initialOldestSequence, initialNewestSequence, initialHasMore]);