feat(api): restore reverse pagination (#685)
* feat(api): restore v3 reverse pagination * docs: add reverse pagination docstrings * docs: document session reverse parameter * fix: add fallback column for ties * refactor: tighten reverse query typing * chore: pre-commit styling * chore(tests): Add additional validation tests and update changelogs --------- Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
parent
4f579d5c66
commit
0cf63c10da
|
|
@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Stream-final retries pin to the `AttemptPlan` that succeeded rather than re-running provider selection through the outer `current_attempt` ContextVar (which could roll streaming back to primary after the tool loop had already switched to fallback)
|
||||
- OpenAI structured-output calls continue to use `chat.completions.parse()` with strict schema enforcement, while tool-calling paths use `chat.completions.create()` without `strict:True` for broader proxy compatibility (OpenRouter, vLLM, Ollama)
|
||||
- Gemini `cached_content` reuse keys now include `system_instruction` and `tool_config` so cache hits don't cross configurations that differ only in those fields
|
||||
- `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages.
|
||||
|
||||
### Removed
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,25 @@
|
|||
},
|
||||
"description": "Page number"
|
||||
},
|
||||
{
|
||||
"name": "reverse",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Whether to reverse the order of results",
|
||||
"default": false,
|
||||
"title": "Reverse"
|
||||
},
|
||||
"description": "Whether to reverse the order of results"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "query",
|
||||
|
|
@ -493,6 +512,25 @@
|
|||
"title": "Workspace Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reverse",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Whether to reverse the order of results",
|
||||
"default": false,
|
||||
"title": "Reverse"
|
||||
},
|
||||
"description": "Whether to reverse the order of results"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
|
|
@ -719,6 +757,25 @@
|
|||
"title": "Peer Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reverse",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Whether to reverse the order of results",
|
||||
"default": false,
|
||||
"title": "Reverse"
|
||||
},
|
||||
"description": "Whether to reverse the order of results"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
|
|
@ -1358,6 +1415,25 @@
|
|||
"title": "Workspace Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reverse",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Whether to reverse the order of results",
|
||||
"default": false,
|
||||
"title": "Reverse"
|
||||
},
|
||||
"description": "Whether to reverse the order of results"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `page`, `size`, and `reverse` pagination parameters on `Honcho.workspaces()` and `HonchoAio.workspaces()`, closing the gap from 2.1.0 which added these to `peers()`, `sessions()`, `messages()`, and `conclusions.list()` but not to `workspaces()`. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter.
|
||||
|
||||
## [2.1.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -92,24 +92,30 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
_honcho: "Honcho"
|
||||
|
||||
def __init__(self, honcho: "Honcho") -> None:
|
||||
"""Create an async view backed by a sync Honcho client."""
|
||||
self._honcho = honcho
|
||||
|
||||
# AsyncMetadataConfigMixin implementation
|
||||
def _get_async_http_client(self):
|
||||
"""Return the async HTTP client used by metadata helpers."""
|
||||
return self._honcho._async_http_client
|
||||
|
||||
def _get_fetch_route(self) -> str:
|
||||
"""Return the workspace fetch route for metadata helpers."""
|
||||
return routes.workspaces()
|
||||
|
||||
def _get_update_route(self) -> str:
|
||||
"""Return the workspace update route for metadata helpers."""
|
||||
return routes.workspace(self._honcho.workspace_id)
|
||||
|
||||
def _get_fetch_body(self) -> dict[str, Any]:
|
||||
"""Return the request body used to fetch this workspace."""
|
||||
return {"id": self._honcho.workspace_id}
|
||||
|
||||
def _parse_response(
|
||||
self, data: dict[str, Any]
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
"""Parse workspace metadata and configuration from an API response."""
|
||||
workspace = WorkspaceResponse.model_validate(data)
|
||||
# Return configuration as dict for mixin compatibility
|
||||
return workspace.metadata or {}, workspace.configuration.model_dump(
|
||||
|
|
@ -117,18 +123,22 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
|
||||
def _set_metadata(self, metadata: dict[str, object]) -> None:
|
||||
"""Update cached workspace metadata on the parent client."""
|
||||
self._honcho._metadata = metadata
|
||||
|
||||
def _set_configuration(self, configuration: dict[str, object]) -> None:
|
||||
"""Update cached workspace configuration on the parent client."""
|
||||
# Convert dict to typed configuration
|
||||
self._honcho._configuration = WorkspaceConfiguration.model_validate(
|
||||
configuration
|
||||
)
|
||||
|
||||
def _get_metadata(self) -> dict[str, object]:
|
||||
"""Return cached workspace metadata from the parent client."""
|
||||
return self._honcho._metadata or {}
|
||||
|
||||
def _get_configuration(self) -> dict[str, object]:
|
||||
"""Return cached workspace configuration from the parent client."""
|
||||
if self._honcho._configuration is None:
|
||||
return {}
|
||||
return self._honcho._configuration.model_dump(exclude_none=True)
|
||||
|
|
@ -216,6 +226,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
|
||||
def transform(peer: PeerResponse) -> Peer:
|
||||
"""Convert a peer API response into a Peer SDK object."""
|
||||
return Peer(
|
||||
peer.id,
|
||||
self._honcho,
|
||||
|
|
@ -225,6 +236,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
|
||||
async def fetch_next(next_page: int) -> AsyncPage[PeerResponse, Peer]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
|
|
@ -318,6 +330,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
|
||||
def transform(session: SessionResponse) -> Session:
|
||||
"""Convert a session API response into a Session SDK object."""
|
||||
return Session(
|
||||
session.id,
|
||||
self._honcho,
|
||||
|
|
@ -328,6 +341,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
|
||||
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
|
|
@ -341,22 +355,37 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
return AsyncPage(data, SessionResponse, transform, fetch_next)
|
||||
|
||||
async def workspaces(
|
||||
self, filters: dict[str, object] | None = None
|
||||
self,
|
||||
filters: dict[str, object] | None = None,
|
||||
*,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[WorkspaceResponse, str]:
|
||||
"""Get all workspace IDs asynchronously."""
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.workspaces_list(),
|
||||
body={"filters": filters} if filters else None,
|
||||
query=query,
|
||||
)
|
||||
|
||||
def transform(workspace: WorkspaceResponse) -> str:
|
||||
"""Convert a workspace API response into its workspace ID."""
|
||||
return workspace.id
|
||||
|
||||
async def fetch_next(page: int) -> AsyncPage[WorkspaceResponse, str]:
|
||||
async def fetch_next(next_page: int) -> AsyncPage[WorkspaceResponse, str]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = await self._honcho._async_http_client.post(
|
||||
routes.workspaces_list(),
|
||||
body={"filters": filters} if filters else None,
|
||||
query={"page": page},
|
||||
query=next_query,
|
||||
)
|
||||
return AsyncPage(next_data, WorkspaceResponse, transform, fetch_next)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,20 +83,25 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
|
||||
# MetadataConfigMixin implementation
|
||||
def _get_http_client(self):
|
||||
"""Return the sync HTTP client used by metadata helpers."""
|
||||
return self._http
|
||||
|
||||
def _get_fetch_route(self) -> str:
|
||||
"""Return the workspace fetch route for metadata helpers."""
|
||||
return routes.workspaces()
|
||||
|
||||
def _get_update_route(self) -> str:
|
||||
"""Return the workspace update route for metadata helpers."""
|
||||
return routes.workspace(self.workspace_id)
|
||||
|
||||
def _get_fetch_body(self) -> dict[str, Any]:
|
||||
"""Return the request body used to fetch this workspace."""
|
||||
return {"id": self.workspace_id}
|
||||
|
||||
def _parse_response(
|
||||
self, data: dict[str, Any]
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
"""Parse workspace metadata and configuration from an API response."""
|
||||
workspace = WorkspaceResponse.model_validate(data)
|
||||
# Return configuration as dict for mixin compatibility
|
||||
return workspace.metadata or {}, workspace.configuration.model_dump(
|
||||
|
|
@ -365,6 +370,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
)
|
||||
|
||||
def transform(peer: PeerResponse) -> Peer:
|
||||
"""Convert a peer API response into a Peer SDK object."""
|
||||
return Peer(
|
||||
peer.id,
|
||||
self,
|
||||
|
|
@ -374,6 +380,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
)
|
||||
|
||||
def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
|
|
@ -483,6 +490,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
)
|
||||
|
||||
def transform(session: SessionResponse) -> Session:
|
||||
"""Convert a session API response into a Session SDK object."""
|
||||
return Session(
|
||||
session.id,
|
||||
self,
|
||||
|
|
@ -493,6 +501,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
)
|
||||
|
||||
def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
|
|
@ -506,7 +515,12 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
return SyncPage(data, SessionResponse, transform, fetch_next)
|
||||
|
||||
def workspaces(
|
||||
self, filters: dict[str, object] | None = None
|
||||
self,
|
||||
filters: dict[str, object] | None = None,
|
||||
*,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[WorkspaceResponse, str]:
|
||||
"""
|
||||
Get all workspace IDs from the Honcho instance.
|
||||
|
|
@ -514,22 +528,38 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
Makes an API call to retrieve all workspace IDs that the authenticated
|
||||
user has access to.
|
||||
|
||||
Args:
|
||||
filters: Optional filter criteria.
|
||||
page: Page number (1-indexed). Default: 1.
|
||||
size: Number of items per page. Default: 50.
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
|
||||
Returns:
|
||||
A paginated SyncPage of workspace ID strings
|
||||
"""
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
|
||||
data = self._http.post(
|
||||
routes.workspaces_list(),
|
||||
body={"filters": filters} if filters else None,
|
||||
query=query,
|
||||
)
|
||||
|
||||
def transform(workspace: WorkspaceResponse) -> str:
|
||||
"""Convert a workspace API response into its workspace ID."""
|
||||
return workspace.id
|
||||
|
||||
def fetch_next(page: int) -> SyncPage[WorkspaceResponse, str]:
|
||||
def fetch_next(next_page: int) -> SyncPage[WorkspaceResponse, str]:
|
||||
"""Fetch the next page while preserving filters and ordering."""
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = self._http.post(
|
||||
routes.workspaces_list(),
|
||||
body={"filters": filters} if filters else None,
|
||||
query={"page": page},
|
||||
query=next_query,
|
||||
)
|
||||
return SyncPage(next_data, WorkspaceResponse, transform, fetch_next)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- `Honcho.workspaces()` now actually forwards the `reverse` option to the server. The 2.1.0 changelog listed `workspaces()` among the list methods that gained `reverse`, but `client.ts` was missing the field on the params type and request builder, so the option was silently dropped. Honoring `reverse` on the workspace/peer/session list routes also requires a Honcho server with the matching API fix; older servers silently ignore the parameter.
|
||||
|
||||
## [2.1.1] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ export class Honcho {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}): Promise<PageResponse<WorkspaceResponse>> {
|
||||
return this._http.post<PageResponse<WorkspaceResponse>>(
|
||||
`/${API_VERSION}/workspaces/list`,
|
||||
|
|
@ -242,6 +243,7 @@ export class Honcho {
|
|||
query: {
|
||||
page: params?.page,
|
||||
size: params?.size,
|
||||
reverse: params?.reverse ? 'true' : undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -707,7 +709,7 @@ export class Honcho {
|
|||
* user has access to.
|
||||
*
|
||||
* @param options - Either a legacy raw filter object or an options object with
|
||||
* `filters`, `page`, and `size`. See
|
||||
* `filters`, `page`, `size`, and `reverse`. See
|
||||
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
|
||||
* @returns Promise resolving to a Page of workspace ID strings. Returns an empty
|
||||
* page if no workspaces are accessible or none exist
|
||||
|
|
@ -719,20 +721,24 @@ export class Honcho {
|
|||
filters?: Filters
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
): Promise<Page<string, WorkspaceResponse>> {
|
||||
const normalizedOptions = normalizeListOptions(options, [
|
||||
'filters',
|
||||
'page',
|
||||
'size',
|
||||
'reverse',
|
||||
])
|
||||
const validatedFilter = normalizedOptions.filters
|
||||
? FilterSchema.parse(normalizedOptions.filters)
|
||||
: undefined
|
||||
const reverse = normalizedOptions.reverse
|
||||
const workspacesPage = await this._listWorkspaces({
|
||||
filters: validatedFilter,
|
||||
page: normalizedOptions.page,
|
||||
size: normalizedOptions.size,
|
||||
reverse,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
|
|
@ -743,6 +749,7 @@ export class Honcho {
|
|||
filters: validatedFilter,
|
||||
page,
|
||||
size,
|
||||
reverse,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface WorkspaceListParams {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -64,6 +65,7 @@ export interface PeerListParams {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export interface PeerChatParams {
|
||||
|
|
@ -141,6 +143,7 @@ export interface SessionListParams {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export interface SessionCloneParams {
|
||||
|
|
@ -226,6 +229,7 @@ export interface MessageListParams {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export interface MessageSearchParams {
|
||||
|
|
@ -262,6 +266,7 @@ export interface ConclusionListParams {
|
|||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export interface ConclusionQueryParams {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""CRUD helpers for peer records and peer-scoped session queries."""
|
||||
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -210,13 +212,17 @@ async def get_peer(
|
|||
|
||||
async def get_peers(
|
||||
workspace_name: str,
|
||||
filters: dict[str, str] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
"""Build a filtered peer list query ordered by creation time."""
|
||||
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
|
||||
|
||||
stmt = apply_filter(stmt, models.Peer, filters)
|
||||
|
||||
return stmt.order_by(models.Peer.created_at)
|
||||
if reverse:
|
||||
return stmt.order_by(models.Peer.created_at.desc(), models.Peer.id.desc())
|
||||
return stmt.order_by(models.Peer.created_at.asc(), models.Peer.id.asc())
|
||||
|
||||
|
||||
async def update_peer(
|
||||
|
|
@ -285,6 +291,7 @@ async def get_sessions_for_peer(
|
|||
workspace_name: str,
|
||||
peer_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all sessions for a peer through the session_peers relationship.
|
||||
|
|
@ -293,6 +300,7 @@ async def get_sessions_for_peer(
|
|||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
filters: Filter sessions by metadata
|
||||
reverse: Whether to reverse the default creation order
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Select statement
|
||||
|
|
@ -310,6 +318,9 @@ async def get_sessions_for_peer(
|
|||
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Session.created_at.desc(), models.Session.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Session.created_at.asc(), models.Session.id.asc())
|
||||
|
||||
return stmt
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""CRUD helpers for sessions and session-related relationship data."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
|
@ -114,9 +116,18 @@ def count_observers_in_config(
|
|||
async def get_sessions(
|
||||
workspace_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all active sessions in a workspace.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
filters: Optional filters to apply to the query
|
||||
reverse: If True, order by created_at descending; if False, ascending
|
||||
|
||||
Returns:
|
||||
Select statement for Session objects
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
|
|
@ -126,7 +137,9 @@ async def get_sessions(
|
|||
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
return stmt.order_by(models.Session.created_at)
|
||||
if reverse:
|
||||
return stmt.order_by(models.Session.created_at.desc(), models.Session.id.desc())
|
||||
return stmt.order_by(models.Session.created_at.asc(), models.Session.id.asc())
|
||||
|
||||
|
||||
async def get_or_create_session(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""CRUD helpers for workspace records and workspace deletion checks."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
|
@ -154,17 +156,25 @@ async def get_or_create_workspace(
|
|||
|
||||
async def get_all_workspaces(
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Workspace]]:
|
||||
"""
|
||||
Get all workspaces.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter the workspaces by a dictionary of metadata
|
||||
reverse: Whether to reverse the default creation order
|
||||
"""
|
||||
stmt = select(models.Workspace)
|
||||
stmt = apply_filter(stmt, models.Workspace, filters)
|
||||
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
|
||||
if reverse:
|
||||
stmt = stmt.order_by(
|
||||
models.Workspace.created_at.desc(), models.Workspace.id.desc()
|
||||
)
|
||||
else:
|
||||
stmt = stmt.order_by(
|
||||
models.Workspace.created_at.asc(), models.Workspace.id.asc()
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""FastAPI routes for peer resources and peer-scoped operations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
|
@ -40,6 +42,7 @@ async def get_peers(
|
|||
options: schemas.PeerGet | None = Body(
|
||||
None, description="Filtering options for the peers list"
|
||||
),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get all Peers for a Workspace, paginated with optional filters."""
|
||||
|
|
@ -51,7 +54,11 @@ async def get_peers(
|
|||
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_peers(workspace_name=workspace_id, filters=filter_param),
|
||||
await crud.get_peers(
|
||||
workspace_name=workspace_id,
|
||||
filters=filter_param,
|
||||
reverse=reverse,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -126,6 +133,7 @@ async def get_sessions_for_peer(
|
|||
options: schemas.SessionGet | None = Body(
|
||||
None, description="Filtering options for the sessions list"
|
||||
),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get all Sessions for a Peer, paginated with optional filters."""
|
||||
|
|
@ -142,6 +150,7 @@ async def get_sessions_for_peer(
|
|||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
filters=filter_param,
|
||||
reverse=reverse,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""FastAPI routes for session resources and session-scoped operations."""
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from time import perf_counter
|
||||
|
|
@ -248,6 +250,7 @@ async def get_sessions(
|
|||
options: schemas.SessionGet | None = Body(
|
||||
None, description="Filtering and pagination options for the sessions list"
|
||||
),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get all Sessions for a Workspace, paginated with optional filters."""
|
||||
|
|
@ -259,7 +262,12 @@ async def get_sessions(
|
|||
filter_param = None
|
||||
|
||||
return await apaginate(
|
||||
db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param)
|
||||
db,
|
||||
await crud.get_sessions(
|
||||
workspace_name=workspace_id,
|
||||
filters=filter_param,
|
||||
reverse=reverse,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"""FastAPI routes for workspace resources and workspace-scoped operations."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
|
||||
|
|
@ -64,6 +66,7 @@ async def get_all_workspaces(
|
|||
options: schemas.WorkspaceGet | None = Body(
|
||||
None, description="Filtering and pagination options for the workspaces list"
|
||||
),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get all Workspaces, paginated with optional filters."""
|
||||
|
|
@ -75,7 +78,7 @@ async def get_all_workspaces(
|
|||
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_all_workspaces(filters=filter_param),
|
||||
await crud.get_all_workspaces(filters=filter_param, reverse=reverse),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -5,7 +6,7 @@ from fastapi.testclient import TestClient
|
|||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud
|
||||
from src import crud, models
|
||||
from src.models import Peer, Workspace
|
||||
|
||||
|
||||
|
|
@ -169,6 +170,148 @@ def test_get_peers_with_null_filter(
|
|||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
def test_get_peers_with_reverse(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test peer listing with reverse creation-time ordering."""
|
||||
test_workspace, _ = sample_data
|
||||
reverse_group = f"reverse-peers-{generate_nanoid()}"
|
||||
first_name = f"reverse-peer-a-{generate_nanoid()}"
|
||||
second_name = f"reverse-peer-b-{generate_nanoid()}"
|
||||
|
||||
first_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers",
|
||||
json={"name": first_name, "metadata": {"reverse_group": reverse_group}},
|
||||
)
|
||||
assert first_response.status_code in [200, 201]
|
||||
|
||||
second_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers",
|
||||
json={"name": second_name, "metadata": {"reverse_group": reverse_group}},
|
||||
)
|
||||
assert second_response.status_code in [200, 201]
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
first_name,
|
||||
second_name,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
second_name,
|
||||
first_name,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_peers_reverse_uses_id_tiebreaker(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Peers with identical created_at fall back to ordering by id (nanoid PK)."""
|
||||
test_workspace, _ = sample_data
|
||||
reverse_group = f"tiebreaker-peers-{generate_nanoid()}"
|
||||
shared_created_at = datetime.datetime(
|
||||
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
low_id = "A" * 21
|
||||
high_id = "z" * 21
|
||||
low_name = f"tie-low-peer-{generate_nanoid()}"
|
||||
high_name = f"tie-high-peer-{generate_nanoid()}"
|
||||
|
||||
db_session.add(
|
||||
models.Peer(
|
||||
id=low_id,
|
||||
name=low_name,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
models.Peer(
|
||||
id=high_id,
|
||||
name=high_name,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
# When created_at ties, ordering falls back to the nanoid id: low_id < high_id
|
||||
# lexicographically, so low sorts first ascending and last descending.
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
low_name,
|
||||
high_name,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
high_name,
|
||||
low_name,
|
||||
]
|
||||
|
||||
|
||||
def test_get_peers_reverse_with_pagination(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Paged reverse listing returns newest-first across consecutive pages."""
|
||||
test_workspace, _ = sample_data
|
||||
reverse_group = f"paged-reverse-peers-{generate_nanoid()}"
|
||||
peer_names = [f"paged-reverse-peer-{i}-{generate_nanoid()}" for i in range(3)]
|
||||
|
||||
for peer_name in peer_names:
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers",
|
||||
json={"name": peer_name, "metadata": {"reverse_group": reverse_group}},
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
page_one = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=1&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
page_two = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=2&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
page_three = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/list?reverse=true&page=3&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_three.status_code == 200
|
||||
|
||||
assert page_one.json()["total"] == 3
|
||||
assert [item["id"] for item in page_one.json()["items"]] == [peer_names[2]]
|
||||
assert [item["id"] for item in page_two.json()["items"]] == [peer_names[1]]
|
||||
assert [item["id"] for item in page_three.json()["items"]] == [peer_names[0]]
|
||||
|
||||
|
||||
def test_update_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
||||
test_workspace, test_peer = sample_data
|
||||
response = client.put(
|
||||
|
|
@ -308,6 +451,159 @@ def test_get_sessions_for_peer_with_empty_filter(
|
|||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
def test_get_sessions_for_peer_with_reverse(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test peer session listing with reverse creation-time ordering."""
|
||||
test_workspace, test_peer = sample_data
|
||||
reverse_group = f"reverse-peer-sessions-{generate_nanoid()}"
|
||||
first_session = f"reverse-peer-session-a-{generate_nanoid()}"
|
||||
second_session = f"reverse-peer-session-b-{generate_nanoid()}"
|
||||
|
||||
first_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": first_session,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert first_response.status_code in [200, 201]
|
||||
|
||||
second_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": second_session,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert second_response.status_code in [200, 201]
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
first_session,
|
||||
second_session,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
second_session,
|
||||
first_session,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_for_peer_reverse_uses_id_tiebreaker(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Peer-scoped sessions with identical created_at fall back to ordering by id."""
|
||||
test_workspace, test_peer = sample_data
|
||||
reverse_group = f"tiebreaker-peer-sessions-{generate_nanoid()}"
|
||||
shared_created_at = datetime.datetime(
|
||||
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
low_id = "A" * 21
|
||||
high_id = "z" * 21
|
||||
low_name = f"tie-low-peer-session-{generate_nanoid()}"
|
||||
high_name = f"tie-high-peer-session-{generate_nanoid()}"
|
||||
|
||||
for session_id, session_name in ((low_id, low_name), (high_id, high_name)):
|
||||
db_session.add(
|
||||
models.Session(
|
||||
id=session_id,
|
||||
name=session_name,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
models.SessionPeer(
|
||||
workspace_name=test_workspace.name,
|
||||
session_name=session_name,
|
||||
peer_name=test_peer.name,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
low_name,
|
||||
high_name,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
high_name,
|
||||
low_name,
|
||||
]
|
||||
|
||||
|
||||
def test_get_sessions_for_peer_reverse_with_pagination(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Paged reverse listing of a peer's sessions returns newest-first across pages."""
|
||||
test_workspace, test_peer = sample_data
|
||||
reverse_group = f"paged-reverse-peer-sessions-{generate_nanoid()}"
|
||||
session_names = [
|
||||
f"paged-reverse-peer-session-{i}-{generate_nanoid()}" for i in range(3)
|
||||
]
|
||||
|
||||
for session_name in session_names:
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": session_name,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
page_one = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=1&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
page_two = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=2&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
page_three = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions?reverse=true&page=3&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_three.status_code == 200
|
||||
|
||||
assert page_one.json()["total"] == 3
|
||||
assert [item["id"] for item in page_one.json()["items"]] == [session_names[2]]
|
||||
assert [item["id"] for item in page_two.json()["items"]] == [session_names[1]]
|
||||
assert [item["id"] for item in page_three.json()["items"]] == [session_names[0]]
|
||||
|
||||
|
||||
def test_chat(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.models import Peer, Workspace
|
||||
|
||||
|
||||
|
|
@ -223,6 +227,160 @@ def test_get_sessions_with_empty_filter(
|
|||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
def test_get_sessions_with_reverse(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test session listing with reverse creation-time ordering."""
|
||||
test_workspace, test_peer = sample_data
|
||||
reverse_group = f"reverse-sessions-{generate_nanoid()}"
|
||||
first_session = f"reverse-session-a-{generate_nanoid()}"
|
||||
second_session = f"reverse-session-b-{generate_nanoid()}"
|
||||
|
||||
first_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": first_session,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert first_response.status_code in [200, 201]
|
||||
|
||||
second_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": second_session,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert second_response.status_code in [200, 201]
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
first_session,
|
||||
second_session,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
second_session,
|
||||
first_session,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_reverse_uses_id_tiebreaker(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Sessions with identical created_at fall back to ordering by id (nanoid PK)."""
|
||||
test_workspace, _ = sample_data
|
||||
reverse_group = f"tiebreaker-sessions-{generate_nanoid()}"
|
||||
shared_created_at = datetime.datetime(
|
||||
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
low_id = "A" * 21
|
||||
high_id = "z" * 21
|
||||
low_name = f"tie-low-{generate_nanoid()}"
|
||||
high_name = f"tie-high-{generate_nanoid()}"
|
||||
|
||||
db_session.add(
|
||||
models.Session(
|
||||
id=low_id,
|
||||
name=low_name,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
models.Session(
|
||||
id=high_id,
|
||||
name=high_name,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
normal_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
# When created_at ties, ordering falls back to the nanoid id: low_id < high_id
|
||||
# lexicographically, so low sorts first ascending and last descending.
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
low_name,
|
||||
high_name,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
high_name,
|
||||
low_name,
|
||||
]
|
||||
|
||||
|
||||
def test_get_sessions_reverse_with_pagination(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Paged reverse listing returns newest-first across consecutive pages."""
|
||||
test_workspace, test_peer = sample_data
|
||||
reverse_group = f"paged-reverse-sessions-{generate_nanoid()}"
|
||||
session_names = [f"paged-reverse-session-{i}-{generate_nanoid()}" for i in range(3)]
|
||||
|
||||
for session_name in session_names:
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": session_name,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
"metadata": {"reverse_group": reverse_group},
|
||||
},
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
page_one = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=1&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
page_two = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=2&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
page_three = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?reverse=true&page=3&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_three.status_code == 200
|
||||
|
||||
assert page_one.json()["total"] == 3
|
||||
assert [item["id"] for item in page_one.json()["items"]] == [session_names[2]]
|
||||
assert [item["id"] for item in page_two.json()["items"]] == [session_names[1]]
|
||||
assert [item["id"] for item in page_three.json()["items"]] == [session_names[0]]
|
||||
|
||||
|
||||
def test_update_delete_metadata(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -125,6 +126,135 @@ async def test_get_all_workspaces_with_null_filter(client: TestClient):
|
|||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_workspaces_with_reverse(client: TestClient):
|
||||
"""Test workspace listing with reverse creation-time ordering."""
|
||||
first_name = f"reverse-workspace-{generate_nanoid()}"
|
||||
second_name = f"reverse-workspace-{generate_nanoid()}"
|
||||
|
||||
first_response = client.post(
|
||||
"/v3/workspaces",
|
||||
json={"name": first_name, "metadata": {"reverse_group": first_name}},
|
||||
)
|
||||
assert first_response.status_code in [200, 201]
|
||||
|
||||
second_response = client.post(
|
||||
"/v3/workspaces",
|
||||
json={"name": second_name, "metadata": {"reverse_group": first_name}},
|
||||
)
|
||||
assert second_response.status_code in [200, 201]
|
||||
|
||||
normal_response = client.post(
|
||||
"/v3/workspaces/list",
|
||||
json={"filters": {"metadata": {"reverse_group": first_name}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
"/v3/workspaces/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": first_name}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
assert [item["id"] for item in normal_response.json()["items"]] == [
|
||||
first_name,
|
||||
second_name,
|
||||
]
|
||||
assert [item["id"] for item in reverse_response.json()["items"]] == [
|
||||
second_name,
|
||||
first_name,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_workspaces_reverse_uses_id_tiebreaker(
|
||||
client: TestClient, db_session: AsyncSession
|
||||
):
|
||||
"""Workspaces with identical created_at fall back to ordering by id (nanoid PK)."""
|
||||
reverse_group = f"tiebreaker-{generate_nanoid()}"
|
||||
shared_created_at = datetime.datetime(
|
||||
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
low_id = "A" * 21
|
||||
high_id = "z" * 21
|
||||
low_name = f"tie-low-{generate_nanoid()}"
|
||||
high_name = f"tie-high-{generate_nanoid()}"
|
||||
|
||||
db_session.add(
|
||||
models.Workspace(
|
||||
id=low_id,
|
||||
name=low_name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
models.Workspace(
|
||||
id=high_id,
|
||||
name=high_name,
|
||||
created_at=shared_created_at,
|
||||
h_metadata={"reverse_group": reverse_group},
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
normal_response = client.post(
|
||||
"/v3/workspaces/list",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert normal_response.status_code == 200
|
||||
|
||||
reverse_response = client.post(
|
||||
"/v3/workspaces/list?reverse=true",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert reverse_response.status_code == 200
|
||||
|
||||
normal_items = [item["id"] for item in normal_response.json()["items"]]
|
||||
reverse_items = [item["id"] for item in reverse_response.json()["items"]]
|
||||
|
||||
# When created_at ties, ordering falls back to the nanoid id: low_id < high_id
|
||||
# lexicographically, so the workspace with id="AAA..." sorts first ascending.
|
||||
assert normal_items == [low_name, high_name]
|
||||
assert reverse_items == [high_name, low_name]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_workspaces_reverse_with_pagination(client: TestClient):
|
||||
"""Paged reverse listing returns newest-first across consecutive pages."""
|
||||
reverse_group = f"paged-reverse-{generate_nanoid()}"
|
||||
names = [f"paged-reverse-{i}-{generate_nanoid()}" for i in range(3)]
|
||||
|
||||
for name in names:
|
||||
response = client.post(
|
||||
"/v3/workspaces",
|
||||
json={"name": name, "metadata": {"reverse_group": reverse_group}},
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
page_one = client.post(
|
||||
"/v3/workspaces/list?reverse=true&page=1&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
page_two = client.post(
|
||||
"/v3/workspaces/list?reverse=true&page=2&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
page_three = client.post(
|
||||
"/v3/workspaces/list?reverse=true&page=3&size=1",
|
||||
json={"filters": {"metadata": {"reverse_group": reverse_group}}},
|
||||
)
|
||||
assert page_three.status_code == 200
|
||||
|
||||
assert page_one.json()["total"] == 3
|
||||
assert [item["id"] for item in page_one.json()["items"]] == [names[2]]
|
||||
assert [item["id"] for item in page_two.json()["items"]] == [names[1]]
|
||||
assert [item["id"] for item in page_three.json()["items"]] == [names[0]]
|
||||
|
||||
|
||||
def test_update_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
||||
test_workspace, _ = sample_data
|
||||
_new_name = str(generate_nanoid())
|
||||
|
|
|
|||
Loading…
Reference in New Issue