mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Merge commit 'fa7075fde6' into dev
This commit is contained in:
commit
b477d33cba
1421 changed files with 59474 additions and 27447 deletions
|
|
@ -37,7 +37,7 @@ def sample_user_id() -> str:
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_search_space_id() -> int:
|
||||
def sample_workspace_id() -> int:
|
||||
return 1
|
||||
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ def make_connector_document():
|
|||
"source_markdown": "## Heading\n\nSome content.",
|
||||
"unique_id": "test-id-001",
|
||||
"document_type": DocumentType.CLICKUP_CONNECTOR,
|
||||
"search_space_id": 1,
|
||||
"workspace_id": 1,
|
||||
"connector_id": 1,
|
||||
"created_by_id": "00000000-0000-0000-0000-000000000001",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ pnpm exec playwright install --with-deps chromium
|
|||
### Each run
|
||||
|
||||
**1. Bring up Postgres + Redis** from the repo root (the other deps-only
|
||||
services (SearXNG, Zero, pgAdmin) are not needed for E2E):
|
||||
services (Zero, pgAdmin) are not needed for E2E):
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.deps-only.yml up -d db redis
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ class FakeChatLLM(BaseChatModel):
|
|||
and CLICKUP_CANARY_TOKEN in latest_tool_text
|
||||
):
|
||||
return f"ClickUp live tool content found: {CLICKUP_CANARY_TOKEN}"
|
||||
if latest_tool_name == "search" and NOTION_CANARY_TOKEN in latest_tool_text:
|
||||
return f"Notion live tool content found: {NOTION_CANARY_TOKEN}"
|
||||
if (
|
||||
latest_tool_name == "searchConfluenceUsingCql"
|
||||
and CONFLUENCE_CANARY_TOKEN in latest_tool_text
|
||||
):
|
||||
return f"Confluence live tool content found: {CONFLUENCE_CANARY_TOKEN}"
|
||||
|
||||
wants_gmail = _contains_any(
|
||||
latest_human,
|
||||
|
|
@ -555,8 +562,8 @@ class FakeChatLLM(BaseChatModel):
|
|||
|
||||
# Marker unique to a connector subagent's prompt: the main agent must
|
||||
# delegate via ``task``; only the subagent has connector tools registered.
|
||||
in_connector_subagent = (
|
||||
"specialist for the user's connected" in _messages_to_text(messages)
|
||||
in_connector_subagent = "connected-apps specialist" in _messages_to_text(
|
||||
messages
|
||||
)
|
||||
|
||||
# Main agent: delegate live-tool connector work to its subagent (which
|
||||
|
|
@ -579,8 +586,12 @@ class FakeChatLLM(BaseChatModel):
|
|||
("linear", ("linear", "issue", LINEAR_CANARY_TITLE)),
|
||||
("slack", ("slack", SLACK_CANARY_TOKEN)),
|
||||
("clickup", ("clickup", CLICKUP_CANARY_TITLE)),
|
||||
("notion", ("notion", NOTION_CANARY_TITLE)),
|
||||
("confluence", ("confluence", CONFLUENCE_CANARY_TITLE)),
|
||||
)
|
||||
for subagent_type, needles in connector_delegations:
|
||||
# Every MCP-backed connector is now one ``mcp_discovery`` route; the
|
||||
# needle set only decides which canary the delegation targets.
|
||||
for connector_key, needles in connector_delegations:
|
||||
if _contains_any(latest_human, needles):
|
||||
return AIMessage(
|
||||
content="",
|
||||
|
|
@ -588,10 +599,10 @@ class FakeChatLLM(BaseChatModel):
|
|||
{
|
||||
"name": "task",
|
||||
"args": {
|
||||
"subagent_type": subagent_type,
|
||||
"subagent_type": "mcp_discovery",
|
||||
"description": latest_human,
|
||||
},
|
||||
"id": f"call_e2e_task_{subagent_type}",
|
||||
"id": f"call_e2e_task_{connector_key}",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
@ -718,6 +729,38 @@ class FakeChatLLM(BaseChatModel):
|
|||
],
|
||||
)
|
||||
|
||||
# Confluence check precedes Notion: the Confluence prompt also contains
|
||||
# the word "page", so Notion's needle omits it to avoid cross-matching.
|
||||
if latest_tool is None and _contains_any(
|
||||
latest_human,
|
||||
("confluence", CONFLUENCE_CANARY_TITLE),
|
||||
):
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "searchConfluenceUsingCql",
|
||||
"args": {"cql": f'text ~ "{CONFLUENCE_CANARY_TITLE}"'},
|
||||
"id": "call_e2e_search_confluence",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
if latest_tool is None and _contains_any(
|
||||
latest_human,
|
||||
("notion", NOTION_CANARY_TITLE),
|
||||
):
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "search",
|
||||
"args": {"query": NOTION_CANARY_TITLE},
|
||||
"id": "call_e2e_search_notion",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _generate(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
"""Strict Jira MCP OAuth/tool fakes for Playwright E2E."""
|
||||
"""Strict Atlassian (Jira + Confluence) MCP OAuth/tool fakes for Playwright E2E.
|
||||
|
||||
Jira and Confluence share one hosted Atlassian Rovo MCP server
|
||||
(``https://mcp.atlassian.com/v1/mcp``) and one OAuth surface. Because the MCP
|
||||
OAuth/runtime dispatchers are keyed by URL, a single handler here serves both
|
||||
connectors; production keeps their tool sets disjoint via each service's
|
||||
``allowed_tools`` curation. The OAuth ``redirect_uri`` substring is therefore
|
||||
relaxed to the shared ``/api/v1/auth/mcp/`` prefix so both the
|
||||
``.../mcp/jira/...`` and ``.../mcp/confluence/...`` callbacks validate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -22,6 +31,13 @@ _ACCESS_TOKEN = "fake-jira-mcp-access-token"
|
|||
_REFRESH_TOKEN = "fake-jira-mcp-refresh-token"
|
||||
_OAUTH_CODE = "fake-jira-oauth-code"
|
||||
|
||||
# Confluence canary — keep in sync with FAKE_CONFLUENCE_PAGES /
|
||||
# CANARY_TOKENS.confluenceCanary in surfsense_web/tests/helpers/canary.ts.
|
||||
_CONFLUENCE_TOKEN = "SURFSENSE_E2E_CANARY_TOKEN_CONFLUENCE_001"
|
||||
_CONFLUENCE_PAGE_ID = "fake-confluence-page-canary-001"
|
||||
_CONFLUENCE_TITLE = "E2E Canary Confluence Page"
|
||||
_CONFLUENCE_SPACE_ID = "fake-confluence-space-001"
|
||||
|
||||
|
||||
def _load_fixture() -> dict[str, Any]:
|
||||
with _FIXTURE_PATH.open() as f:
|
||||
|
|
@ -69,6 +85,34 @@ async def _list_tools() -> SimpleNamespace:
|
|||
"required": ["jql"],
|
||||
},
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="searchConfluenceUsingCql",
|
||||
description="Search Confluence content using a CQL expression.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cql": {
|
||||
"type": "string",
|
||||
"description": "CQL query used to search Confluence content.",
|
||||
}
|
||||
},
|
||||
"required": ["cql"],
|
||||
},
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="getConfluencePage",
|
||||
description="Fetch a Confluence page's body by id.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pageId": {
|
||||
"type": "string",
|
||||
"description": "The Confluence page id to fetch.",
|
||||
}
|
||||
},
|
||||
"required": ["pageId"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -98,11 +142,30 @@ async def _call_tool(
|
|||
text = _issue_text(issue)
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
raise NotImplementedError(f"Unexpected Jira MCP tool call: {tool_name!r}")
|
||||
if tool_name == "searchConfluenceUsingCql":
|
||||
cql = str(arguments.get("cql", ""))
|
||||
if _CONFLUENCE_TITLE.lower() not in cql.lower():
|
||||
raise ValueError(f"Unexpected Confluence CQL query: {cql!r}")
|
||||
text = (
|
||||
f"{_CONFLUENCE_TITLE}\n"
|
||||
f"id: {_CONFLUENCE_PAGE_ID}\n"
|
||||
f"space: {_CONFLUENCE_SPACE_ID}\n"
|
||||
f"excerpt: {_CONFLUENCE_TOKEN}"
|
||||
)
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
if tool_name == "getConfluencePage":
|
||||
page_id = str(arguments.get("pageId", ""))
|
||||
if page_id != _CONFLUENCE_PAGE_ID:
|
||||
raise ValueError(f"Unexpected Confluence page id: {page_id!r}")
|
||||
text = f"{_CONFLUENCE_TITLE}\n\n{_CONFLUENCE_TOKEN}"
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
raise NotImplementedError(f"Unexpected Atlassian MCP tool call: {tool_name!r}")
|
||||
|
||||
|
||||
def install(active_patches: list[Any]) -> None:
|
||||
"""Register Jira MCP OAuth/tool handlers with the shared dispatchers."""
|
||||
"""Register the shared Atlassian (Jira + Confluence) MCP handlers."""
|
||||
del active_patches
|
||||
mcp_oauth_runtime.register_service(
|
||||
mcp_url=_MCP_URL,
|
||||
|
|
@ -122,8 +185,10 @@ def install(active_patches: list[Any]) -> None:
|
|||
oauth_code=_OAUTH_CODE,
|
||||
access_token=_ACCESS_TOKEN,
|
||||
refresh_token=_REFRESH_TOKEN,
|
||||
scope="read:jira-work read:me write:jira-work",
|
||||
redirect_uri_substring="/api/v1/auth/mcp/jira/connector/callback",
|
||||
scope="read:jira-work read:confluence-content.all read:me write:jira-work",
|
||||
# Shared Atlassian server serves both Jira and Confluence connectors, so
|
||||
# accept either service's callback under the common MCP OAuth prefix.
|
||||
redirect_uri_substring="/api/v1/auth/mcp/",
|
||||
)
|
||||
mcp_runtime.register(
|
||||
url=_MCP_URL,
|
||||
|
|
|
|||
|
|
@ -430,15 +430,15 @@ def install(active_patches: list[Any]) -> None:
|
|||
("app.connectors.google_gmail_connector.build", _fake_build),
|
||||
("app.connectors.google_calendar_connector.build", _fake_build),
|
||||
(
|
||||
"app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.create_event.build",
|
||||
"app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.create_event.build",
|
||||
_fake_build,
|
||||
),
|
||||
(
|
||||
"app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.update_event.build",
|
||||
"app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.update_event.build",
|
||||
_fake_build,
|
||||
),
|
||||
(
|
||||
"app.agents.chat.multi_agent_chat.subagents.connectors.calendar.tools.delete_event.build",
|
||||
"app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.calendar.delete_event.build",
|
||||
_fake_build,
|
||||
),
|
||||
("googleapiclient.http.MediaIoBaseDownload", _FakeMediaIoBaseDownload),
|
||||
|
|
|
|||
127
surfsense_backend/tests/e2e/fakes/notion_mcp_module.py
Normal file
127
surfsense_backend/tests/e2e/fakes/notion_mcp_module.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Strict Notion MCP OAuth/tool fakes for Playwright E2E.
|
||||
|
||||
Notion migrated from indexed OAuth to the hosted Notion MCP server
|
||||
(``https://mcp.notion.com/mcp``, DCR/RFC 7591). This fake mirrors
|
||||
``jira_module`` for the generic MCP OAuth + streamable-HTTP tool boundaries;
|
||||
the older ``notion_module`` (a ``notion_client`` SDK stand-in) stays only to
|
||||
satisfy production's import-time ``import notion_client``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from tests.e2e.fakes import mcp_oauth_runtime, mcp_runtime
|
||||
|
||||
_AUTHORIZATION_URL = "https://mcp.notion.com/authorize"
|
||||
_REGISTRATION_URL = "https://mcp.notion.com/register"
|
||||
_TOKEN_URL = "https://mcp.notion.com/token"
|
||||
_MCP_URL = "https://mcp.notion.com/mcp"
|
||||
|
||||
_CLIENT_ID = "fake-notion-mcp-client-id"
|
||||
_CLIENT_SECRET = "fake-notion-mcp-client-secret"
|
||||
_ACCESS_TOKEN = "fake-notion-mcp-access-token"
|
||||
_REFRESH_TOKEN = "fake-notion-mcp-refresh-token"
|
||||
_OAUTH_CODE = "fake-notion-oauth-code"
|
||||
|
||||
# Keep in sync with FAKE_NOTION_PAGES / CANARY_TOKENS.notionCanary in
|
||||
# surfsense_web/tests/helpers/canary.ts.
|
||||
_CANARY_TOKEN = "SURFSENSE_E2E_CANARY_TOKEN_NOTION_001"
|
||||
_CANARY_PAGE_ID = "fake-notion-page-canary-001"
|
||||
_CANARY_TITLE = "E2E Canary Notion Page"
|
||||
_WORKSPACE_NAME = "SurfSense E2E Notion Workspace"
|
||||
|
||||
|
||||
async def _list_tools() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
tools=[
|
||||
SimpleNamespace(
|
||||
name="search",
|
||||
description="Search the connected Notion workspace for pages and databases.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Text to search Notion pages for.",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="fetch",
|
||||
description="Fetch the full contents of a Notion page by id.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Notion page id to fetch.",
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _call_tool(
|
||||
tool_name: str, arguments: dict[str, Any] | None = None
|
||||
) -> SimpleNamespace:
|
||||
arguments = arguments or {}
|
||||
|
||||
if tool_name == "search":
|
||||
query = str(arguments.get("query", ""))
|
||||
if _CANARY_TITLE.lower() not in query.lower():
|
||||
raise ValueError(f"Unexpected Notion search query: {query!r}")
|
||||
text = (
|
||||
f"{_CANARY_TITLE}\n"
|
||||
f"id: {_CANARY_PAGE_ID}\n"
|
||||
f"workspace: {_WORKSPACE_NAME}\n"
|
||||
f"snippet: {_CANARY_TOKEN}"
|
||||
)
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
if tool_name == "fetch":
|
||||
page_id = str(arguments.get("id", ""))
|
||||
if page_id != _CANARY_PAGE_ID:
|
||||
raise ValueError(f"Unexpected Notion fetch id: {page_id!r}")
|
||||
text = f"{_CANARY_TITLE}\n\n{_CANARY_TOKEN}"
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
raise NotImplementedError(f"Unexpected Notion MCP tool call: {tool_name!r}")
|
||||
|
||||
|
||||
def install(active_patches: list[Any]) -> None:
|
||||
"""Register Notion MCP OAuth/tool handlers with the shared dispatchers."""
|
||||
del active_patches
|
||||
mcp_oauth_runtime.register_service(
|
||||
mcp_url=_MCP_URL,
|
||||
discovery_metadata={
|
||||
"issuer": "https://mcp.notion.com",
|
||||
"authorization_endpoint": _AUTHORIZATION_URL,
|
||||
"token_endpoint": _TOKEN_URL,
|
||||
"registration_endpoint": _REGISTRATION_URL,
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"response_types_supported": ["code"],
|
||||
},
|
||||
client_id=_CLIENT_ID,
|
||||
client_secret=_CLIENT_SECRET,
|
||||
token_endpoint=_TOKEN_URL,
|
||||
registration_endpoint=_REGISTRATION_URL,
|
||||
oauth_code=_OAUTH_CODE,
|
||||
access_token=_ACCESS_TOKEN,
|
||||
refresh_token=_REFRESH_TOKEN,
|
||||
scope="read write",
|
||||
redirect_uri_substring="/api/v1/auth/mcp/notion/connector/callback",
|
||||
)
|
||||
mcp_runtime.register(
|
||||
url=_MCP_URL,
|
||||
expected_bearer=_ACCESS_TOKEN,
|
||||
list_tools=_list_tools,
|
||||
call_tool=_call_tool,
|
||||
)
|
||||
|
|
@ -282,6 +282,7 @@ def _install_runtime_fakes() -> None:
|
|||
mcp_oauth_runtime as _fake_mcp_oauth_runtime,
|
||||
mcp_runtime as _fake_mcp_runtime,
|
||||
native_google as _fake_native_google,
|
||||
notion_mcp_module as _fake_notion_mcp_module,
|
||||
notion_module as _fake_notion_module,
|
||||
onedrive_graph as _fake_onedrive_graph,
|
||||
slack_module as _fake_slack_module,
|
||||
|
|
@ -295,6 +296,7 @@ def _install_runtime_fakes() -> None:
|
|||
_fake_onedrive_graph.install(_active_patches)
|
||||
_fake_dropbox_api.install(_active_patches)
|
||||
_fake_notion_module.install(_active_patches)
|
||||
_fake_notion_mcp_module.install(_active_patches)
|
||||
_fake_linear_module.install(_active_patches)
|
||||
_fake_jira_module.install(_active_patches)
|
||||
_fake_clickup_module.install(_active_patches)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from app.agents.chat.multi_agent_chat.shared.retrieval.hybrid_search import (
|
|||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.retrieval.models import SearchScope
|
||||
from app.config import config
|
||||
from app.db import Chunk, Document, DocumentType, SearchSpace
|
||||
from app.db import Chunk, Document, DocumentType, Workspace
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ def _axis(index: int) -> list[float]:
|
|||
async def _add_document(
|
||||
db_session,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
title: str = "Doc",
|
||||
document_type: DocumentType = DocumentType.FILE,
|
||||
state: str = "ready",
|
||||
|
|
@ -47,7 +47,7 @@ async def _add_document(
|
|||
document_type=document_type,
|
||||
content="\n".join(content for content, _, _ in chunks),
|
||||
content_hash=uuid.uuid4().hex,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
status={"state": state},
|
||||
)
|
||||
db_session.add(document)
|
||||
|
|
@ -65,17 +65,17 @@ async def _add_document(
|
|||
return document
|
||||
|
||||
|
||||
async def test_keyword_relevant_document_is_retrieved(db_session, db_search_space):
|
||||
async def test_keyword_relevant_document_is_retrieved(db_session, db_workspace):
|
||||
document = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Asyncio Guide",
|
||||
chunks=[("The asyncio library enables concurrency.", 0, _axis(0))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(),
|
||||
top_k=5,
|
||||
|
|
@ -85,23 +85,23 @@ async def test_keyword_relevant_document_is_retrieved(db_session, db_search_spac
|
|||
assert document.id in {hit.document_id for hit in results}
|
||||
|
||||
|
||||
async def test_semantically_closest_document_ranks_first(db_session, db_search_space):
|
||||
async def test_semantically_closest_document_ranks_first(db_session, db_workspace):
|
||||
aligned = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Background Work",
|
||||
chunks=[("Parallel execution of background work.", 0, _axis(0))],
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Dessert",
|
||||
chunks=[("Recipes for chocolate cake.", 0, _axis(1))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asynchronous coroutines",
|
||||
scope=SearchScope(),
|
||||
top_k=5,
|
||||
|
|
@ -111,25 +111,25 @@ async def test_semantically_closest_document_ranks_first(db_session, db_search_s
|
|||
assert results[0].document_id == aligned.id
|
||||
|
||||
|
||||
async def test_results_stay_within_the_search_space(db_session, db_search_space):
|
||||
other_space = SearchSpace(name="Other Space", user_id=db_search_space.user_id)
|
||||
async def test_results_stay_within_the_workspace(db_session, db_workspace):
|
||||
other_space = Workspace(name="Other Space", user_id=db_workspace.user_id)
|
||||
db_session.add(other_space)
|
||||
await db_session.flush()
|
||||
|
||||
mine = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
|
||||
)
|
||||
foreign = await _add_document(
|
||||
db_session,
|
||||
search_space_id=other_space.id,
|
||||
workspace_id=other_space.id,
|
||||
chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(),
|
||||
top_k=5,
|
||||
|
|
@ -140,21 +140,21 @@ async def test_results_stay_within_the_search_space(db_session, db_search_space)
|
|||
assert mine.id in found and foreign.id not in found
|
||||
|
||||
|
||||
async def test_document_ids_scope_pins_results(db_session, db_search_space):
|
||||
async def test_document_ids_scope_pins_results(db_session, db_workspace):
|
||||
pinned = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
chunks=[("asyncio appears in the pinned doc.", 0, _axis(0))],
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
chunks=[("asyncio appears in the other doc too.", 0, _axis(0))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(document_ids=(pinned.id,)),
|
||||
top_k=5,
|
||||
|
|
@ -164,22 +164,22 @@ async def test_document_ids_scope_pins_results(db_session, db_search_space):
|
|||
assert {hit.document_id for hit in results} == {pinned.id}
|
||||
|
||||
|
||||
async def test_deleting_documents_are_excluded(db_session, db_search_space):
|
||||
async def test_deleting_documents_are_excluded(db_session, db_workspace):
|
||||
ready = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
chunks=[("asyncio in a ready document.", 0, _axis(0))],
|
||||
)
|
||||
deleting = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
state="deleting",
|
||||
chunks=[("asyncio in a deleting document.", 0, _axis(0))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(),
|
||||
top_k=5,
|
||||
|
|
@ -190,12 +190,12 @@ async def test_deleting_documents_are_excluded(db_session, db_search_space):
|
|||
assert ready.id in found and deleting.id not in found
|
||||
|
||||
|
||||
async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_space):
|
||||
async def test_matched_chunks_are_ordered_for_reading(db_session, db_workspace):
|
||||
# Insert out of order, and give the later-position chunk the stronger
|
||||
# semantic score, so reading order differs from both insertion and score.
|
||||
document = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
chunks=[
|
||||
("asyncio paragraph two.", 1, _axis(0)),
|
||||
("asyncio paragraph one.", 0, _axis(50)),
|
||||
|
|
@ -204,7 +204,7 @@ async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_spac
|
|||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(),
|
||||
top_k=5,
|
||||
|
|
@ -215,18 +215,18 @@ async def test_matched_chunks_are_ordered_for_reading(db_session, db_search_spac
|
|||
assert [chunk.position for chunk in hit.chunks] == [0, 1]
|
||||
|
||||
|
||||
async def test_top_k_caps_the_number_of_documents(db_session, db_search_space):
|
||||
async def test_top_k_caps_the_number_of_documents(db_session, db_workspace):
|
||||
for index in range(3):
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title=f"Doc {index}",
|
||||
chunks=[(f"asyncio mentioned in doc {index}.", 0, _axis(index))],
|
||||
)
|
||||
|
||||
results = await search_chunks(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
query="asyncio",
|
||||
scope=SearchScope(),
|
||||
top_k=2,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ def _axis(index: int) -> list[float]:
|
|||
async def _add_document(
|
||||
db_session,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
title: str,
|
||||
text: str,
|
||||
folder_id: int | None = None,
|
||||
|
|
@ -58,7 +58,7 @@ async def _add_document(
|
|||
document_type=DocumentType.FILE,
|
||||
content=text,
|
||||
content_hash=uuid.uuid4().hex,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
folder_id=folder_id,
|
||||
status={"state": "ready"},
|
||||
)
|
||||
|
|
@ -71,8 +71,8 @@ async def _add_document(
|
|||
return document
|
||||
|
||||
|
||||
async def _add_folder(db_session, *, search_space_id: int, name: str = "Folder"):
|
||||
folder = Folder(name=name, position="0", search_space_id=search_space_id)
|
||||
async def _add_folder(db_session, *, workspace_id: int, name: str = "Folder"):
|
||||
folder = Folder(name=name, position="0", workspace_id=workspace_id)
|
||||
db_session.add(folder)
|
||||
await db_session.flush()
|
||||
return folder
|
||||
|
|
@ -109,15 +109,15 @@ def _mentions(*, document_ids=(), folder_ids=()):
|
|||
|
||||
|
||||
async def test_tool_returns_retrieved_context_with_numbered_passages(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Asyncio Guide",
|
||||
text="The asyncio library enables concurrency.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, "asyncio")
|
||||
|
||||
|
|
@ -129,15 +129,15 @@ async def test_tool_returns_retrieved_context_with_numbered_passages(
|
|||
|
||||
|
||||
async def test_tool_populates_citation_registry_on_state(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Asyncio Guide",
|
||||
text="The asyncio library enables concurrency.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, "asyncio")
|
||||
|
||||
|
|
@ -147,15 +147,15 @@ async def test_tool_populates_citation_registry_on_state(
|
|||
|
||||
|
||||
async def test_tool_reuses_existing_registry_numbering(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Asyncio Guide",
|
||||
text="The asyncio library enables concurrency.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
first = await _invoke(tool, "asyncio")
|
||||
carried = first.update["citation_registry"]
|
||||
|
|
@ -166,9 +166,9 @@ async def test_tool_reuses_existing_registry_numbering(
|
|||
|
||||
|
||||
async def test_tool_reports_no_matches_without_touching_state(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, "nonexistent-term-zzz")
|
||||
|
||||
|
|
@ -177,9 +177,9 @@ async def test_tool_reports_no_matches_without_touching_state(
|
|||
|
||||
|
||||
async def test_tool_rejects_empty_query(
|
||||
db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, " ")
|
||||
|
||||
|
|
@ -188,21 +188,21 @@ async def test_tool_rejects_empty_query(
|
|||
|
||||
|
||||
async def test_document_mention_confines_search_to_pinned_doc(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
pinned = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Pinned",
|
||||
text="asyncio appears in the pinned doc.",
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Other",
|
||||
text="asyncio appears in the other doc.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, "asyncio", context=_mentions(document_ids=[pinned.id]))
|
||||
|
||||
|
|
@ -213,23 +213,23 @@ async def test_document_mention_confines_search_to_pinned_doc(
|
|||
|
||||
|
||||
async def test_folder_mention_confines_search_to_folder_documents(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
folder = await _add_folder(db_session, search_space_id=db_search_space.id)
|
||||
folder = await _add_folder(db_session, workspace_id=db_workspace.id)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Inside",
|
||||
text="asyncio appears inside the folder.",
|
||||
folder_id=folder.id,
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Outside",
|
||||
text="asyncio appears outside the folder.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(tool, "asyncio", context=_mentions(folder_ids=[folder.id]))
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ async def test_folder_mention_confines_search_to_folder_documents(
|
|||
|
||||
|
||||
async def test_document_mention_via_state_confines_search(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
"""The real subagent path: mentions arrive on ``runtime.state`` (no context).
|
||||
|
||||
|
|
@ -250,17 +250,17 @@ async def test_document_mention_via_state_confines_search(
|
|||
"""
|
||||
pinned = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Pinned",
|
||||
text="asyncio appears in the pinned doc.",
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Other",
|
||||
text="asyncio appears in the other doc.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(
|
||||
tool,
|
||||
|
|
@ -275,24 +275,24 @@ async def test_document_mention_via_state_confines_search(
|
|||
|
||||
|
||||
async def test_folder_mention_via_state_confines_search(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
"""Folder pins delivered via state (subagent path) scope to the folder's docs."""
|
||||
folder = await _add_folder(db_session, search_space_id=db_search_space.id)
|
||||
folder = await _add_folder(db_session, workspace_id=db_workspace.id)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Inside",
|
||||
text="asyncio appears inside the folder.",
|
||||
folder_id=folder.id,
|
||||
)
|
||||
await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Outside",
|
||||
text="asyncio appears outside the folder.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(
|
||||
tool,
|
||||
|
|
@ -307,22 +307,22 @@ async def test_folder_mention_via_state_confines_search(
|
|||
|
||||
|
||||
async def test_state_mentions_take_precedence_over_context(
|
||||
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
|
||||
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
|
||||
):
|
||||
"""When both carry pins, state wins (the forwarded subagent pin is authoritative)."""
|
||||
state_doc = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="StatePinned",
|
||||
text="asyncio appears in the state-pinned doc.",
|
||||
)
|
||||
context_doc = await _add_document(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="ContextPinned",
|
||||
text="asyncio appears in the context-pinned doc.",
|
||||
)
|
||||
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
|
||||
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
|
||||
|
||||
result = await _invoke(
|
||||
tool,
|
||||
|
|
|
|||
|
|
@ -35,18 +35,18 @@ def _last_ai_text(messages: list) -> str | None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_space):
|
||||
async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_workspace):
|
||||
"""A freshly assembled agent streams a scripted final-text turn to completion."""
|
||||
harness = build_scripted_harness(turns=[ScriptedTurn(text="done")])
|
||||
|
||||
agent = await create_multi_agent_chat_deep_agent(
|
||||
llm=harness.model,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
db_session=db_session,
|
||||
connector_service=ConnectorService(db_session),
|
||||
checkpointer=InMemorySaver(),
|
||||
user_id=str(db_user.id),
|
||||
thread_id=db_search_space.id,
|
||||
thread_id=db_workspace.id,
|
||||
agent_config=None,
|
||||
)
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_sp
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_space):
|
||||
async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_workspace):
|
||||
"""The compiled graph routes a model tool call to its tool and resumes."""
|
||||
harness = build_scripted_harness(
|
||||
turns=[
|
||||
|
|
@ -79,12 +79,12 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
|
|||
|
||||
agent = await create_multi_agent_chat_deep_agent(
|
||||
llm=harness.model,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
db_session=db_session,
|
||||
connector_service=ConnectorService(db_session),
|
||||
checkpointer=InMemorySaver(),
|
||||
user_id=str(db_user.id),
|
||||
thread_id=db_search_space.id,
|
||||
thread_id=db_workspace.id,
|
||||
agent_config=None,
|
||||
additional_tools=harness.tools,
|
||||
)
|
||||
|
|
@ -101,7 +101,7 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_checkpoint_round_trips_across_turns(
|
||||
db_session, db_user, db_search_space
|
||||
db_session, db_user, db_workspace
|
||||
):
|
||||
"""Turn 2 sees turn 1's history, proving the checkpoint serializes and reloads.
|
||||
|
||||
|
|
@ -118,12 +118,12 @@ async def test_agent_checkpoint_round_trips_across_turns(
|
|||
async def _build():
|
||||
return await create_multi_agent_chat_deep_agent(
|
||||
llm=harness.model,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
db_session=db_session,
|
||||
connector_service=ConnectorService(db_session),
|
||||
checkpointer=checkpointer,
|
||||
user_id=str(db_user.id),
|
||||
thread_id=db_search_space.id,
|
||||
thread_id=db_workspace.id,
|
||||
agent_config=None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,16 +40,16 @@ _SEARCH_SPACE_ID = 1
|
|||
def _build_cloud_fs_mw():
|
||||
"""Build the production filesystem middleware in cloud mode.
|
||||
|
||||
A non-None ``search_space_id`` makes the resolver hand out a
|
||||
A non-None ``workspace_id`` makes the resolver hand out a
|
||||
``KBPostgresBackend``, exactly as production does. Staging operations never
|
||||
touch the DB, so a dummy id is sufficient for these tests.
|
||||
"""
|
||||
selection = FilesystemSelection(mode=FilesystemMode.CLOUD)
|
||||
resolver = build_backend_resolver(selection, search_space_id=_SEARCH_SPACE_ID)
|
||||
resolver = build_backend_resolver(selection, workspace_id=_SEARCH_SPACE_ID)
|
||||
return build_filesystem_mw(
|
||||
backend_resolver=resolver,
|
||||
filesystem_mode=FilesystemMode.CLOUD,
|
||||
search_space_id=_SEARCH_SPACE_ID,
|
||||
workspace_id=_SEARCH_SPACE_ID,
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
thread_id=_SEARCH_SPACE_ID,
|
||||
read_only=False,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def _build_desktop_fs_mw(root: Path):
|
|||
return build_filesystem_mw(
|
||||
backend_resolver=resolver,
|
||||
filesystem_mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
thread_id=1,
|
||||
read_only=False,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
"""Backend E2E: public web search now flows through the ``google_search`` route.
|
||||
|
||||
After the Google-only consolidation the main agent has no ``web_search`` tool;
|
||||
a web query must be delegated to the ``google_search`` subagent via ``task``.
|
||||
This drives the *assembled* production graph (real DB, scripted LLM) end to end:
|
||||
main agent emits a ``task(google_search, ...)`` call, the subagent runs a turn,
|
||||
and the main agent resumes to a final answer. Proves the delegation path the
|
||||
teardown relies on actually executes -- not just that the constants changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from app.agents.chat.multi_agent_chat import create_multi_agent_chat_deep_agent
|
||||
from app.services.connector_service import ConnectorService
|
||||
from tests.integration.harness import ScriptedTurn, build_scripted_harness
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _last_ai_text(messages: list) -> str | None:
|
||||
for m in reversed(messages):
|
||||
if isinstance(m, AIMessage) and isinstance(m.content, str) and m.content:
|
||||
return m.content
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_query_delegates_to_google_search(db_session, db_user, db_workspace):
|
||||
"""A web-search query routes through ``task(google_search)`` and resumes.
|
||||
|
||||
Scripted sequence (the fake model is shared and consumed in order across the
|
||||
main agent and the delegated subagent):
|
||||
1. main agent -> task(subagent_type="google_search")
|
||||
2. subagent -> plain text (never touches the scrape tool, so no network)
|
||||
3. main agent -> final answer
|
||||
"""
|
||||
harness = build_scripted_harness(
|
||||
turns=[
|
||||
ScriptedTurn(
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "task",
|
||||
"args": {
|
||||
"subagent_type": "google_search",
|
||||
"description": "latest SurfSense release notes",
|
||||
},
|
||||
"id": "call_ws_task",
|
||||
}
|
||||
]
|
||||
),
|
||||
ScriptedTurn(text="SERP: SurfSense v2 shipped Google-only web search."),
|
||||
ScriptedTurn(text="SurfSense v2 shipped Google-only web search."),
|
||||
]
|
||||
)
|
||||
|
||||
agent = await create_multi_agent_chat_deep_agent(
|
||||
llm=harness.model,
|
||||
workspace_id=db_workspace.id,
|
||||
db_session=db_session,
|
||||
connector_service=ConnectorService(db_session),
|
||||
checkpointer=InMemorySaver(),
|
||||
user_id=str(db_user.id),
|
||||
thread_id=db_workspace.id,
|
||||
agent_config=None,
|
||||
)
|
||||
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content="search the web for SurfSense news")]},
|
||||
config={"configurable": {"thread_id": "ws-google-delegation-1"}},
|
||||
)
|
||||
|
||||
task_tool_messages = [
|
||||
m for m in result["messages"] if isinstance(m, ToolMessage) and m.name == "task"
|
||||
]
|
||||
assert task_tool_messages, "web query did not delegate through the task tool"
|
||||
assert _last_ai_text(result["messages"]) == (
|
||||
"SurfSense v2 shipped Google-only web search."
|
||||
)
|
||||
66
surfsense_backend/tests/integration/automations/conftest.py
Normal file
66
surfsense_backend/tests/integration/automations/conftest.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Shared fixtures for automations integration tests.
|
||||
|
||||
Bridges the code-under-test to the transactional ``db_session`` so real
|
||||
behavior runs against real Postgres and rolls back at test end:
|
||||
|
||||
* ``client`` — httpx over ASGI with ``get_async_session``/``get_auth_context``
|
||||
overridden to the test session + owner.
|
||||
* ``enqueue_spy`` — capture ``automation_run_execute.apply_async`` so run-now
|
||||
can be asserted without a Redis broker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.app import app
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import User, get_async_session
|
||||
from app.users import get_auth_context
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(
|
||||
db_session: AsyncSession, db_user: User
|
||||
) -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
async def override_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield db_session
|
||||
|
||||
async def override_auth() -> AuthContext:
|
||||
return AuthContext.session(db_user)
|
||||
|
||||
previous = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[get_async_session] = override_session
|
||||
app.dependency_overrides[get_auth_context] = override_auth
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
timeout=30.0,
|
||||
follow_redirects=False,
|
||||
) as c:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
app.dependency_overrides.update(previous)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enqueue_spy(monkeypatch) -> list[dict]:
|
||||
"""Capture Celery enqueues so run-now needs no broker."""
|
||||
import app.automations.dispatch.launch as launch_mod
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def _spy(*args, **kwargs):
|
||||
calls.append({"args": args, "kwargs": kwargs})
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(launch_mod.automation_run_execute, "apply_async", _spy)
|
||||
return calls
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"""The durable checkpointer survives Celery's fresh-loop-per-task model.
|
||||
|
||||
Slice 1's whole point: the shared ``AsyncPostgresSaver`` pool binds connections
|
||||
to the loop that opened them, and Celery runs each task on a new loop. This
|
||||
writes a checkpoint on one ``run_async_celery_task`` loop and reads it back on a
|
||||
*fresh* one — proving the per-task pool dispose lets a new loop reopen and read
|
||||
committed state, rather than stalling on a stale connection.
|
||||
|
||||
Uses the real pool against the real (test) Postgres, so a regression in the
|
||||
dispose wiring fails here, not just in production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from app.agents.chat.runtime.checkpointer import close_checkpointer, get_checkpointer
|
||||
from app.tasks.celery_tasks import run_async_celery_task
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _config(thread_id: str) -> dict:
|
||||
return {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
|
||||
|
||||
def test_checkpoint_written_on_one_loop_is_readable_on_a_fresh_loop() -> None:
|
||||
thread_id = f"cross-loop-{uuid.uuid4()}"
|
||||
config = _config(thread_id)
|
||||
checkpoint = empty_checkpoint()
|
||||
|
||||
async def _write() -> None:
|
||||
cp = await get_checkpointer()
|
||||
await cp.aput(config, checkpoint, {"source": "update", "step": 0}, {})
|
||||
|
||||
async def _read():
|
||||
cp = await get_checkpointer()
|
||||
return await cp.aget_tuple(config)
|
||||
|
||||
async def _cleanup() -> None:
|
||||
cp = await get_checkpointer()
|
||||
delete = getattr(cp, "adelete_thread", None)
|
||||
if delete is not None:
|
||||
await delete(thread_id)
|
||||
|
||||
# Loop 1 writes and commits; run_async_celery_task disposes the pool after.
|
||||
run_async_celery_task(_write)
|
||||
|
||||
# Loop 2 is a brand-new event loop: a stale loop-bound pool would stall
|
||||
# here (PoolTimeout). It must reopen and read the committed checkpoint.
|
||||
tup = run_async_celery_task(_read)
|
||||
|
||||
try:
|
||||
assert tup is not None, "fresh loop could not read the prior checkpoint"
|
||||
assert tup.checkpoint["id"] == checkpoint["id"]
|
||||
finally:
|
||||
run_async_celery_task(_cleanup)
|
||||
run_async_celery_task(lambda: close_checkpointer())
|
||||
|
|
@ -46,9 +46,9 @@ from app.db import (
|
|||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
NewChatThread,
|
||||
SearchSpace,
|
||||
TokenUsage,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.routes import new_chat_routes
|
||||
from app.services.token_tracking_service import TurnTokenAccumulator
|
||||
|
|
@ -69,11 +69,11 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_thread(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
) -> NewChatThread:
|
||||
thread = NewChatThread(
|
||||
title="Test Chat",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=db_user.id,
|
||||
visibility=ChatVisibility.PRIVATE,
|
||||
)
|
||||
|
|
@ -109,7 +109,7 @@ def bypass_permission_checks(monkeypatch):
|
|||
"""Replace RBAC + thread access checks with no-ops.
|
||||
|
||||
The append_message route under test calls ``check_permission`` and
|
||||
``check_thread_access``; those rely on a SearchSpaceMembership row
|
||||
``check_thread_access``; those rely on a WorkspaceMembership row
|
||||
that the existing integration fixtures don't create. The contract
|
||||
we want to verify here is the ``IntegrityError`` -> recovery branch,
|
||||
not the RBAC plumbing — so stub them.
|
||||
|
|
@ -208,7 +208,7 @@ class TestToolHeavyTurnFinalize:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
"""End-to-end seam: builder snapshot -> finalize -> DB row.
|
||||
|
|
@ -221,7 +221,7 @@ class TestToolHeavyTurnFinalize:
|
|||
"""
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:tool_heavy"
|
||||
|
||||
msg_id = await persist_assistant_shell(
|
||||
|
|
@ -258,7 +258,7 @@ class TestToolHeavyTurnFinalize:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=snapshot,
|
||||
|
|
@ -300,7 +300,7 @@ class TestToolHeavyTurnFinalize:
|
|||
assert usage.total_tokens == 280
|
||||
assert usage.cost_micros == 22222
|
||||
assert usage.thread_id == thread_id
|
||||
assert usage.search_space_id == search_space_id
|
||||
assert usage.workspace_id == workspace_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -314,7 +314,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
bypass_permission_checks,
|
||||
):
|
||||
|
|
@ -337,7 +337,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
"""
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:fe_late_append"
|
||||
|
||||
# Step 1: server stream completes. Server-built rich content is
|
||||
|
|
@ -353,7 +353,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=server_content,
|
||||
|
|
@ -439,7 +439,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
bypass_permission_checks,
|
||||
):
|
||||
|
|
@ -456,7 +456,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
"""
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:fe_first"
|
||||
|
||||
# Step 1: legacy FE appendMessage lands first. No prior shell
|
||||
|
|
@ -490,7 +490,7 @@ class TestAppendMessageRecoveryAfterFinalize:
|
|||
await finalize_assistant_turn(
|
||||
message_id=adopted_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=server_content,
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ from app.db import (
|
|||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
NewChatThread,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.services.new_streaming_service import VercelStreamingService
|
||||
from app.tasks.chat import persistence as persistence_module
|
||||
|
|
@ -68,11 +68,11 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_thread(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
) -> NewChatThread:
|
||||
thread = NewChatThread(
|
||||
title="Test Chat",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=db_user.id,
|
||||
visibility=ChatVisibility.PRIVATE,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ from app.db import (
|
|||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
NewChatThread,
|
||||
SearchSpace,
|
||||
TokenUsage,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.services.token_tracking_service import TurnTokenAccumulator
|
||||
from app.tasks.chat import persistence as persistence_module
|
||||
|
|
@ -60,11 +60,11 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_thread(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
) -> NewChatThread:
|
||||
thread = NewChatThread(
|
||||
title="Test Chat",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=db_user.id,
|
||||
visibility=ChatVisibility.PRIVATE,
|
||||
)
|
||||
|
|
@ -537,13 +537,13 @@ class TestFinalizeAssistantTurn:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
thread_id = db_thread.id
|
||||
user_id_uuid = db_user.id
|
||||
user_id_str = str(user_id_uuid)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:4000"
|
||||
|
||||
msg_id = await persist_assistant_shell(
|
||||
|
|
@ -568,7 +568,7 @@ class TestFinalizeAssistantTurn:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=rich_content,
|
||||
|
|
@ -597,19 +597,19 @@ class TestFinalizeAssistantTurn:
|
|||
assert usage.total_tokens == 150
|
||||
assert usage.cost_micros == 12345
|
||||
assert usage.thread_id == thread_id
|
||||
assert usage.search_space_id == search_space_id
|
||||
assert usage.workspace_id == workspace_id
|
||||
|
||||
async def test_empty_content_writes_status_marker(
|
||||
self,
|
||||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:5000"
|
||||
|
||||
msg_id = await persist_assistant_shell(
|
||||
|
|
@ -624,7 +624,7 @@ class TestFinalizeAssistantTurn:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=[],
|
||||
|
|
@ -640,12 +640,12 @@ class TestFinalizeAssistantTurn:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:6000"
|
||||
|
||||
msg_id = await persist_assistant_shell(
|
||||
|
|
@ -659,7 +659,7 @@ class TestFinalizeAssistantTurn:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=[{"type": "text", "text": "first finalize"}],
|
||||
|
|
@ -681,7 +681,7 @@ class TestFinalizeAssistantTurn:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=[{"type": "text", "text": "second finalize"}],
|
||||
|
|
@ -708,7 +708,7 @@ class TestFinalizeAssistantTurn:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
"""Cross-writer race: ``append_message`` arrives after ``finalize_assistant_turn``.
|
||||
|
|
@ -722,7 +722,7 @@ class TestFinalizeAssistantTurn:
|
|||
thread_id = db_thread.id
|
||||
user_uuid = db_user.id
|
||||
user_id_str = str(user_uuid)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
turn_id = f"{thread_id}:7000"
|
||||
|
||||
msg_id = await persist_assistant_shell(
|
||||
|
|
@ -735,7 +735,7 @@ class TestFinalizeAssistantTurn:
|
|||
await finalize_assistant_turn(
|
||||
message_id=msg_id,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id=turn_id,
|
||||
content=[{"type": "text", "text": "from server"}],
|
||||
|
|
@ -758,7 +758,7 @@ class TestFinalizeAssistantTurn:
|
|||
call_details=None,
|
||||
thread_id=thread_id,
|
||||
message_id=msg_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_uuid,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
|
|
@ -783,19 +783,19 @@ class TestFinalizeAssistantTurn:
|
|||
db_session,
|
||||
db_user,
|
||||
db_thread,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
patched_shielded_session,
|
||||
):
|
||||
thread_id = db_thread.id
|
||||
user_id_str = str(db_user.id)
|
||||
search_space_id = db_search_space.id
|
||||
workspace_id = db_workspace.id
|
||||
|
||||
# message_id that doesn't exist — finalize must log+return,
|
||||
# never raise (called from shielded finally).
|
||||
await finalize_assistant_turn(
|
||||
message_id=999_999_999,
|
||||
chat_id=thread_id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id_str,
|
||||
turn_id="anything",
|
||||
content=[{"type": "text", "text": "x"}],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
These tests exercise the route handlers directly with real DB-backed
|
||||
users, memberships, and permissions. The important contract is that a
|
||||
thread shared with a search space stays shared across normal metadata
|
||||
thread shared with a workspace stays shared across normal metadata
|
||||
updates until the creator explicitly makes it private again.
|
||||
"""
|
||||
|
||||
|
|
@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.auth.context import AuthContext
|
||||
from app.db import (
|
||||
ChatVisibility,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
SearchSpaceRole,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMembership,
|
||||
WorkspaceRole,
|
||||
)
|
||||
from app.routes import new_chat_routes
|
||||
from app.schemas.new_chat import (
|
||||
|
|
@ -39,7 +39,7 @@ def _auth(user: User) -> AuthContext:
|
|||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> User:
|
||||
async def db_member(db_session: AsyncSession, db_workspace: Workspace) -> User:
|
||||
member = User(
|
||||
id=uuid.uuid4(),
|
||||
email="member@surfsense.net",
|
||||
|
|
@ -54,9 +54,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
|
|||
role = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(SearchSpaceRole).where(
|
||||
SearchSpaceRole.search_space_id == db_search_space.id,
|
||||
SearchSpaceRole.name == "Editor",
|
||||
select(WorkspaceRole).where(
|
||||
WorkspaceRole.workspace_id == db_workspace.id,
|
||||
WorkspaceRole.name == "Editor",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -64,9 +64,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
|
|||
.one()
|
||||
)
|
||||
db_session.add(
|
||||
SearchSpaceMembership(
|
||||
WorkspaceMembership(
|
||||
user_id=member.id,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
role_id=role.id,
|
||||
is_owner=False,
|
||||
)
|
||||
|
|
@ -78,7 +78,7 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
|
|||
async def _create_thread(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
*,
|
||||
title: str = "Visibility Invariant Chat",
|
||||
):
|
||||
|
|
@ -86,7 +86,7 @@ async def _create_thread(
|
|||
NewChatThreadCreate(
|
||||
title=title,
|
||||
archived=False,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
visibility=ChatVisibility.PRIVATE,
|
||||
),
|
||||
session=db_session,
|
||||
|
|
@ -102,21 +102,21 @@ def _search_thread_ids(response) -> set[int]:
|
|||
return {thread.id for thread in response}
|
||||
|
||||
|
||||
async def test_private_thread_is_hidden_from_other_search_space_member(
|
||||
async def test_private_thread_is_hidden_from_other_workspace_member(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_member: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
thread = await _create_thread(db_session, db_user, db_search_space)
|
||||
thread = await _create_thread(db_session, db_user, db_workspace)
|
||||
|
||||
member_threads = await new_chat_routes.list_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
)
|
||||
member_search = await new_chat_routes.search_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Visibility",
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
|
|
@ -137,9 +137,9 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
|
|||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_member: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
thread = await _create_thread(db_session, db_user, db_search_space)
|
||||
thread = await _create_thread(db_session, db_user, db_workspace)
|
||||
|
||||
updated = await new_chat_routes.update_thread_visibility(
|
||||
thread_id=thread.id,
|
||||
|
|
@ -151,12 +151,12 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
|
|||
)
|
||||
|
||||
member_threads = await new_chat_routes.list_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
)
|
||||
member_search = await new_chat_routes.search_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Visibility",
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
|
|
@ -177,9 +177,9 @@ async def test_creator_can_share_thread_and_member_can_list_search_read_it(
|
|||
async def test_rename_and_archive_do_not_reset_shared_visibility(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
thread = await _create_thread(db_session, db_user, db_search_space)
|
||||
thread = await _create_thread(db_session, db_user, db_workspace)
|
||||
await new_chat_routes.update_thread_visibility(
|
||||
thread_id=thread.id,
|
||||
visibility_update=NewChatThreadVisibilityUpdate(
|
||||
|
|
@ -211,9 +211,9 @@ async def test_non_creator_cannot_change_shared_thread_back_to_private(
|
|||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_member: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
thread = await _create_thread(db_session, db_user, db_search_space)
|
||||
thread = await _create_thread(db_session, db_user, db_workspace)
|
||||
await new_chat_routes.update_thread_visibility(
|
||||
thread_id=thread.id,
|
||||
visibility_update=NewChatThreadVisibilityUpdate(
|
||||
|
|
@ -240,9 +240,9 @@ async def test_creator_can_make_shared_thread_private_again(
|
|||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_member: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
thread = await _create_thread(db_session, db_user, db_search_space)
|
||||
thread = await _create_thread(db_session, db_user, db_workspace)
|
||||
await new_chat_routes.update_thread_visibility(
|
||||
thread_id=thread.id,
|
||||
visibility_update=NewChatThreadVisibilityUpdate(
|
||||
|
|
@ -261,12 +261,12 @@ async def test_creator_can_make_shared_thread_private_again(
|
|||
auth=_auth(db_user),
|
||||
)
|
||||
member_threads = await new_chat_routes.list_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
)
|
||||
member_search = await new_chat_routes.search_threads(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Visibility",
|
||||
session=db_session,
|
||||
auth=_auth(db_member),
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async def client(
|
|||
async def drive_connector(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
) -> SearchSourceConnector:
|
||||
connector = SearchSourceConnector(
|
||||
name="Google Drive (Composio) - e2e-fake@surfsense.example",
|
||||
|
|
@ -77,7 +77,7 @@ async def drive_connector(
|
|||
"toolkit_name": "Google Drive",
|
||||
"is_indexable": True,
|
||||
},
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=db_user.id,
|
||||
)
|
||||
db_session.add(connector)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from app.config import config
|
|||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.utils.oauth_security import OAuthStateManager
|
||||
|
||||
|
|
@ -29,12 +29,12 @@ async def _drive_connectors(
|
|||
session: AsyncSession,
|
||||
*,
|
||||
user_id: UUID,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
) -> list[SearchSourceConnector]:
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
SearchSourceConnector.user_id == user_id,
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
SearchSourceConnector.workspace_id == workspace_id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
)
|
||||
|
|
@ -46,9 +46,9 @@ async def test_callback_with_error_param_redirects_to_denied_page(
|
|||
client: httpx.AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
state = _state_for(db_search_space.id, db_user.id)
|
||||
state = _state_for(db_workspace.id, db_user.id)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/auth/composio/connector/callback?state={state}&error=access_denied"
|
||||
|
|
@ -57,14 +57,13 @@ async def test_callback_with_error_param_redirects_to_denied_page(
|
|||
assert response.status_code in {302, 303, 307}
|
||||
location = response.headers["location"]
|
||||
assert (
|
||||
f"/dashboard/{db_search_space.id}/connectors/callback?"
|
||||
"error=composio_oauth_denied"
|
||||
f"/dashboard/{db_workspace.id}/connectors/callback?error=composio_oauth_denied"
|
||||
) in location
|
||||
|
||||
connectors = await _drive_connectors(
|
||||
db_session,
|
||||
user_id=db_user.id,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
assert connectors == []
|
||||
|
||||
|
|
@ -73,9 +72,9 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
|
|||
client: httpx.AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
first_state = _state_for(db_search_space.id, db_user.id)
|
||||
first_state = _state_for(db_workspace.id, db_user.id)
|
||||
|
||||
first_response = await client.get(
|
||||
"/api/v1/auth/composio/connector/callback"
|
||||
|
|
@ -86,7 +85,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
|
|||
first_connectors = await _drive_connectors(
|
||||
db_session,
|
||||
user_id=db_user.id,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
assert len(first_connectors) == 1
|
||||
first_connector = first_connectors[0]
|
||||
|
|
@ -94,7 +93,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
|
|||
"fake-acct-googledrive-first"
|
||||
)
|
||||
|
||||
second_state = _state_for(db_search_space.id, db_user.id)
|
||||
second_state = _state_for(db_workspace.id, db_user.id)
|
||||
second_response = await client.get(
|
||||
"/api/v1/auth/composio/connector/callback"
|
||||
f"?state={second_state}&connectedAccountId=fake-acct-googledrive-second"
|
||||
|
|
@ -104,7 +103,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
|
|||
second_connectors = await _drive_connectors(
|
||||
db_session,
|
||||
user_id=db_user.id,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
assert len(second_connectors) == 1
|
||||
assert second_connectors[0].id == first_connector.id
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ Base = app_db.Base
|
|||
DocumentType = app_db.DocumentType
|
||||
SearchSourceConnector = app_db.SearchSourceConnector
|
||||
SearchSourceConnectorType = app_db.SearchSourceConnectorType
|
||||
SearchSpace = app_db.SearchSpace
|
||||
Workspace = app_db.Workspace
|
||||
User = app_db.User
|
||||
ConnectorDocument = importlib.import_module(
|
||||
"app.indexing_pipeline.connector_document"
|
||||
).ConnectorDocument
|
||||
create_default_roles_and_membership = importlib.import_module(
|
||||
"app.routes.search_spaces_routes"
|
||||
"app.routes.workspaces_routes"
|
||||
).create_default_roles_and_membership
|
||||
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL
|
||||
|
||||
|
|
@ -95,13 +95,13 @@ async def db_user(db_session: AsyncSession) -> User:
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_connector(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: "SearchSpace"
|
||||
db_session: AsyncSession, db_user: User, db_workspace: "Workspace"
|
||||
) -> SearchSourceConnector:
|
||||
connector = SearchSourceConnector(
|
||||
name="Test Connector",
|
||||
connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR,
|
||||
config={},
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=db_user.id,
|
||||
)
|
||||
db_session.add(connector)
|
||||
|
|
@ -110,14 +110,14 @@ async def db_connector(
|
|||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpace:
|
||||
space = SearchSpace(
|
||||
async def db_workspace(db_session: AsyncSession, db_user: User) -> Workspace:
|
||||
space = Workspace(
|
||||
name="Test Space",
|
||||
user_id=db_user.id,
|
||||
)
|
||||
db_session.add(space)
|
||||
await db_session.flush()
|
||||
# Mirror POST /searchspaces so routes guarded by check_permission find a membership.
|
||||
# Mirror POST /workspaces so routes guarded by check_permission find a membership.
|
||||
await create_default_roles_and_membership(db_session, space.id, db_user.id)
|
||||
await db_session.flush()
|
||||
return space
|
||||
|
|
@ -180,7 +180,7 @@ def make_connector_document(db_connector, db_user):
|
|||
"source_markdown": "## Heading\n\nSome content.",
|
||||
"unique_id": "test-id-001",
|
||||
"document_type": DocumentType.CLICKUP_CONNECTOR,
|
||||
"search_space_id": db_connector.search_space_id,
|
||||
"workspace_id": db_connector.workspace_id,
|
||||
"connector_id": db_connector.id,
|
||||
"created_by_id": str(db_user.id),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from tests.utils.helpers import (
|
|||
auth_headers,
|
||||
delete_document,
|
||||
get_auth_token,
|
||||
get_search_space_id,
|
||||
get_workspace_id,
|
||||
)
|
||||
|
||||
limiter.enabled = False
|
||||
|
|
@ -66,7 +66,7 @@ class InlineTaskDispatcher:
|
|||
document_id: int,
|
||||
temp_path: str,
|
||||
filename: str,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
user_id: str,
|
||||
use_vision_llm: bool = False,
|
||||
processing_mode: str = "basic",
|
||||
|
|
@ -80,7 +80,7 @@ class InlineTaskDispatcher:
|
|||
document_id,
|
||||
temp_path,
|
||||
filename,
|
||||
search_space_id,
|
||||
workspace_id,
|
||||
user_id,
|
||||
use_vision_llm=use_vision_llm,
|
||||
processing_mode=processing_mode,
|
||||
|
|
@ -107,7 +107,7 @@ async def _ensure_tables():
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth & search space (session-scoped, via the in-process app)
|
||||
# Auth & workspace (session-scoped, via the in-process app)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -121,12 +121,12 @@ async def auth_token(_ensure_tables) -> str:
|
|||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def search_space_id(auth_token: str) -> int:
|
||||
"""Discover the first search space belonging to the test user."""
|
||||
async def workspace_id(auth_token: str) -> int:
|
||||
"""Discover the first workspace belonging to the test user."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test", timeout=30.0
|
||||
) as c:
|
||||
return await get_search_space_id(c, auth_token)
|
||||
return await get_workspace_id(c, auth_token)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
@ -155,19 +155,19 @@ def cleanup_doc_ids() -> list[int]:
|
|||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
async def _purge_test_search_space(search_space_id: int):
|
||||
async def _purge_test_workspace(workspace_id: int):
|
||||
"""Delete stale documents from previous runs before the session starts."""
|
||||
conn = await asyncpg.connect(_ASYNCPG_URL)
|
||||
try:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM documents WHERE search_space_id = $1",
|
||||
search_space_id,
|
||||
"DELETE FROM documents WHERE workspace_id = $1",
|
||||
workspace_id,
|
||||
)
|
||||
deleted = int(result.split()[-1])
|
||||
if deleted:
|
||||
print(
|
||||
f"\n[purge] Deleted {deleted} stale document(s) "
|
||||
f"from search space {search_space_id}"
|
||||
f"from workspace {workspace_id}"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ class TestTxtFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
|
@ -54,18 +54,18 @@ class TestTxtFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id
|
||||
client, headers, doc_ids, workspace_id=workspace_id
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "ready"
|
||||
|
|
@ -78,18 +78,18 @@ class TestPdfFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "ready"
|
||||
|
|
@ -107,14 +107,14 @@ class TestMultiFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_multiple_files(
|
||||
client,
|
||||
headers,
|
||||
["sample.txt", "sample.md"],
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
|
@ -139,22 +139,22 @@ class TestDuplicateFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp1 = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
first_ids = resp1.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(first_ids)
|
||||
|
||||
await poll_document_status(
|
||||
client, headers, first_ids, search_space_id=search_space_id
|
||||
client, headers, first_ids, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
resp2 = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
|
@ -179,18 +179,18 @@ class TestDuplicateContentDetection:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
tmp_path: Path,
|
||||
):
|
||||
resp1 = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
first_ids = resp1.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(first_ids)
|
||||
await poll_document_status(
|
||||
client, headers, first_ids, search_space_id=search_space_id
|
||||
client, headers, first_ids, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
src = FIXTURES_DIR / "sample.txt"
|
||||
|
|
@ -202,7 +202,7 @@ class TestDuplicateContentDetection:
|
|||
"/api/v1/documents/fileupload",
|
||||
headers=headers,
|
||||
files={"files": ("renamed_sample.txt", f)},
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
second_ids = resp2.json()["document_ids"]
|
||||
|
|
@ -212,7 +212,7 @@ class TestDuplicateContentDetection:
|
|||
)
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, second_ids, search_space_id=search_space_id
|
||||
client, headers, second_ids, workspace_id=workspace_id
|
||||
)
|
||||
for did in second_ids:
|
||||
assert statuses[did]["status"]["state"] == "failed"
|
||||
|
|
@ -231,11 +231,11 @@ class TestEmptyFileUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_file(
|
||||
client, headers, "empty.pdf", search_space_id=search_space_id
|
||||
client, headers, "empty.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ class TestEmptyFileUpload:
|
|||
assert doc_ids, "Expected at least one document id for empty PDF upload"
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=120.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=120.0
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "failed"
|
||||
|
|
@ -264,14 +264,14 @@ class TestUnauthenticatedUpload:
|
|||
async def test_upload_without_auth_returns_401(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
):
|
||||
file_path = FIXTURES_DIR / "sample.txt"
|
||||
with open(file_path, "rb") as f:
|
||||
resp = await client.post(
|
||||
"/api/v1/documents/fileupload",
|
||||
files={"files": ("sample.txt", f)},
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
|
@ -288,12 +288,12 @@ class TestNoFilesUpload:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/documents/fileupload",
|
||||
headers=headers,
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp.status_code in {400, 422}
|
||||
|
||||
|
|
@ -310,24 +310,22 @@ class TestDocumentSearchability:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id
|
||||
)
|
||||
await poll_document_status(client, headers, doc_ids, workspace_id=workspace_id)
|
||||
|
||||
search_resp = await client.get(
|
||||
"/api/v1/documents/search",
|
||||
headers=headers,
|
||||
params={"title": "sample", "search_space_id": search_space_id},
|
||||
params={"title": "sample", "workspace_id": workspace_id},
|
||||
)
|
||||
assert search_resp.status_code == 200
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestBalanceDecrementsOnSuccess:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
|
|
@ -65,14 +65,14 @@ class TestBalanceDecrementsOnSuccess:
|
|||
before = await _get_balance(client, headers)
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "ready"
|
||||
|
|
@ -94,21 +94,21 @@ class TestUploadRejectedWhenCreditExhausted:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
await credits.set(balance_micros=0)
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "failed"
|
||||
|
|
@ -121,21 +121,21 @@ class TestUploadRejectedWhenCreditExhausted:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
await credits.set(balance_micros=0)
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
|
||||
balance = await _get_balance(client, headers)
|
||||
|
|
@ -157,28 +157,28 @@ class TestInsufficientCreditsNotification:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
await credits.set(balance_micros=0)
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
|
||||
notifications = await get_notifications(
|
||||
client,
|
||||
headers,
|
||||
type_filter="insufficient_credits",
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
assert len(notifications) >= 1, (
|
||||
"Expected at least one insufficient_credits notification"
|
||||
|
|
@ -206,28 +206,26 @@ class TestDocumentProcessingNotification:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
await credits.set(balance_micros=credits.pages(1000))
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "sample.txt", search_space_id=search_space_id
|
||||
client, headers, "sample.txt", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(doc_ids)
|
||||
|
||||
await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id
|
||||
)
|
||||
await poll_document_status(client, headers, doc_ids, workspace_id=workspace_id)
|
||||
|
||||
notifications = await get_notifications(
|
||||
client,
|
||||
headers,
|
||||
type_filter="document_processing",
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
completed = [
|
||||
n
|
||||
|
|
@ -252,7 +250,7 @@ class TestBalanceUnchangedOnProcessingFailure:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
|
|
@ -260,7 +258,7 @@ class TestBalanceUnchangedOnProcessingFailure:
|
|||
await credits.set(balance_micros=starting)
|
||||
|
||||
resp = await upload_file(
|
||||
client, headers, "empty.pdf", search_space_id=search_space_id
|
||||
client, headers, "empty.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
doc_ids = resp.json()["document_ids"]
|
||||
|
|
@ -268,7 +266,7 @@ class TestBalanceUnchangedOnProcessingFailure:
|
|||
|
||||
if doc_ids:
|
||||
statuses = await poll_document_status(
|
||||
client, headers, doc_ids, search_space_id=search_space_id, timeout=120.0
|
||||
client, headers, doc_ids, workspace_id=workspace_id, timeout=120.0
|
||||
)
|
||||
for did in doc_ids:
|
||||
assert statuses[did]["status"]["state"] == "failed"
|
||||
|
|
@ -292,7 +290,7 @@ class TestSecondUploadExceedsCredit:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
credits,
|
||||
):
|
||||
|
|
@ -301,14 +299,14 @@ class TestSecondUploadExceedsCredit:
|
|||
await credits.set(balance_micros=credits.pages(1))
|
||||
|
||||
resp1 = await upload_file(
|
||||
client, headers, "sample.pdf", search_space_id=search_space_id
|
||||
client, headers, "sample.pdf", workspace_id=workspace_id
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
first_ids = resp1.json()["document_ids"]
|
||||
cleanup_doc_ids.extend(first_ids)
|
||||
|
||||
statuses1 = await poll_document_status(
|
||||
client, headers, first_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, first_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
for did in first_ids:
|
||||
assert statuses1[did]["status"]["state"] == "ready"
|
||||
|
|
@ -317,7 +315,7 @@ class TestSecondUploadExceedsCredit:
|
|||
client,
|
||||
headers,
|
||||
"sample.pdf",
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
filename_override="sample_copy.pdf",
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
|
|
@ -325,7 +323,7 @@ class TestSecondUploadExceedsCredit:
|
|||
cleanup_doc_ids.extend(second_ids)
|
||||
|
||||
statuses2 = await poll_document_status(
|
||||
client, headers, second_ids, search_space_id=search_space_id, timeout=300.0
|
||||
client, headers, second_ids, workspace_id=workspace_id, timeout=300.0
|
||||
)
|
||||
for did in second_ids:
|
||||
assert statuses2[did]["status"]["state"] == "failed"
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ class TestStripeCheckoutSessionCreation:
|
|||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
monkeypatch,
|
||||
):
|
||||
checkout_session = SimpleNamespace(
|
||||
|
|
@ -205,7 +205,7 @@ class TestStripeCheckoutSessionCreation:
|
|||
response = await client.post(
|
||||
"/api/v1/stripe/create-credit-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 2, "search_space_id": search_space_id},
|
||||
json={"quantity": 2, "workspace_id": workspace_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -217,12 +217,12 @@ class TestStripeCheckoutSessionCreation:
|
|||
]
|
||||
assert (
|
||||
fake_client.last_params["success_url"]
|
||||
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
|
||||
== f"http://localhost:3000/dashboard/{workspace_id}/purchase-success"
|
||||
"?session_id={CHECKOUT_SESSION_ID}"
|
||||
)
|
||||
assert (
|
||||
fake_client.last_params["cancel_url"]
|
||||
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel"
|
||||
== f"http://localhost:3000/dashboard/{workspace_id}/purchase-cancel"
|
||||
)
|
||||
assert fake_client.last_params["metadata"]["purchase_type"] == "credits"
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ class TestStripeCheckoutSessionCreation:
|
|||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False)
|
||||
|
|
@ -252,7 +252,7 @@ class TestStripeCheckoutSessionCreation:
|
|||
response = await client.post(
|
||||
"/api/v1/stripe/create-credit-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 2, "search_space_id": search_space_id},
|
||||
json={"quantity": 2, "workspace_id": workspace_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 503, response.text
|
||||
|
|
@ -270,7 +270,7 @@ class TestStripeWebhookFulfillment:
|
|||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
credits,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -291,7 +291,7 @@ class TestStripeWebhookFulfillment:
|
|||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-credit-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 3, "search_space_id": search_space_id},
|
||||
json={"quantity": 3, "workspace_id": workspace_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
|
||||
|
|
@ -359,7 +359,7 @@ class TestStripeReconciliation:
|
|||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
credits,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -380,7 +380,7 @@ class TestStripeReconciliation:
|
|||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-credit-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 3, "search_space_id": search_space_id},
|
||||
json={"quantity": 3, "workspace_id": workspace_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
assert await _get_balance(TEST_EMAIL) == 1_000_000
|
||||
|
|
@ -433,7 +433,7 @@ class TestStripeReconciliation:
|
|||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
credits,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -454,7 +454,7 @@ class TestStripeReconciliation:
|
|||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-credit-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 1, "search_space_id": search_space_id},
|
||||
json={"quantity": 1, "workspace_id": workspace_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ class TestPerFileSizeLimit:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
):
|
||||
oversized = io.BytesIO(b"\x00" * (500 * 1024 * 1024 + 1))
|
||||
resp = await client.post(
|
||||
"/api/v1/documents/fileupload",
|
||||
headers=headers,
|
||||
files=[("files", ("big.pdf", oversized, "application/pdf"))],
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
assert "per-file limit" in resp.json()["detail"].lower()
|
||||
|
|
@ -50,7 +50,7 @@ class TestPerFileSizeLimit:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
at_limit = io.BytesIO(b"\x00" * (500 * 1024 * 1024))
|
||||
|
|
@ -58,7 +58,7 @@ class TestPerFileSizeLimit:
|
|||
"/api/v1/documents/fileupload",
|
||||
headers=headers,
|
||||
files=[("files", ("exact500mb.txt", at_limit, "text/plain"))],
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cleanup_doc_ids.extend(resp.json().get("document_ids", []))
|
||||
|
|
@ -76,7 +76,7 @@ class TestNoFileCountLimit:
|
|||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
cleanup_doc_ids: list[int],
|
||||
):
|
||||
files = [
|
||||
|
|
@ -87,7 +87,7 @@ class TestNoFileCountLimit:
|
|||
"/api/v1/documents/fileupload",
|
||||
headers=headers,
|
||||
files=files,
|
||||
data={"search_space_id": str(search_space_id)},
|
||||
data={"workspace_id": str(workspace_id)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cleanup_doc_ids.extend(resp.json().get("document_ids", []))
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ from app.db import (
|
|||
DocumentType,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
EMBEDDING_DIM = app_config.embedding_model_instance.dimension
|
||||
|
|
@ -31,7 +31,7 @@ def make_document(
|
|||
title: str,
|
||||
document_type: DocumentType,
|
||||
content: str,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str,
|
||||
) -> Document:
|
||||
"""Build a Document instance with unique hashes and a dummy embedding."""
|
||||
|
|
@ -43,7 +43,7 @@ def make_document(
|
|||
content_hash=f"content-{uid}",
|
||||
unique_identifier_hash=f"uid-{uid}",
|
||||
source_markdown=content,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
embedding=DUMMY_EMBEDDING,
|
||||
updated_at=datetime.now(UTC),
|
||||
|
|
@ -66,35 +66,35 @@ def make_chunk(*, content: str, document_id: int) -> Chunk:
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_google_docs(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Insert a native Drive doc, a legacy Composio Drive doc, and a FILE doc.
|
||||
|
||||
Returns a dict with keys ``native_doc``, ``legacy_doc``, ``file_doc``,
|
||||
plus ``search_space`` and ``user``.
|
||||
plus ``workspace`` and ``user``.
|
||||
"""
|
||||
user_id = str(db_user.id)
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
|
||||
native_doc = make_document(
|
||||
title="Native Drive Document",
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
content="quarterly report from native google drive connector",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
legacy_doc = make_document(
|
||||
title="Legacy Composio Drive Document",
|
||||
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
content="quarterly report from composio google drive connector",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
file_doc = make_document(
|
||||
title="Uploaded PDF",
|
||||
document_type=DocumentType.FILE,
|
||||
content="unrelated uploaded file about quarterly reports",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ async def seed_google_docs(
|
|||
"native_doc": native_doc,
|
||||
"legacy_doc": legacy_doc,
|
||||
"file_doc": file_doc,
|
||||
"search_space": db_search_space,
|
||||
"workspace": db_workspace,
|
||||
"user": db_user,
|
||||
}
|
||||
|
||||
|
|
@ -136,8 +136,8 @@ async def seed_google_docs(
|
|||
async def committed_google_data(async_engine):
|
||||
"""Insert native, legacy, and FILE docs via a committed transaction.
|
||||
|
||||
Yields ``{"search_space_id": int, "user_id": str}``.
|
||||
Cleans up by deleting the search space (cascades to documents / chunks).
|
||||
Yields ``{"workspace_id": int, "user_id": str}``.
|
||||
Cleans up by deleting the workspace (cascades to documents / chunks).
|
||||
"""
|
||||
space_id = None
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ async def committed_google_data(async_engine):
|
|||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
space = SearchSpace(name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id)
|
||||
space = Workspace(name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id)
|
||||
session.add(space)
|
||||
await session.flush()
|
||||
space_id = space.id
|
||||
|
|
@ -165,21 +165,21 @@ async def committed_google_data(async_engine):
|
|||
title="Native Drive Doc",
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
content="quarterly budget from native google drive",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
legacy_doc = make_document(
|
||||
title="Legacy Composio Drive Doc",
|
||||
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
content="quarterly budget from composio google drive",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
file_doc = make_document(
|
||||
title="Plain File",
|
||||
document_type=DocumentType.FILE,
|
||||
content="quarterly budget uploaded as file",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
session.add_all([native_doc, legacy_doc, file_doc])
|
||||
|
|
@ -195,11 +195,11 @@ async def committed_google_data(async_engine):
|
|||
)
|
||||
await session.flush()
|
||||
|
||||
yield {"search_space_id": space_id, "user_id": user_id}
|
||||
yield {"workspace_id": space_id, "user_id": user_id}
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("DELETE FROM searchspaces WHERE id = :sid"), {"sid": space_id}
|
||||
text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ async def seed_connector(
|
|||
):
|
||||
"""Seed a connector with committed data. Returns dict and cleanup function.
|
||||
|
||||
Yields ``{"connector_id", "search_space_id", "user_id"}``.
|
||||
Yields ``{"connector_id", "workspace_id", "user_id"}``.
|
||||
"""
|
||||
space_id = None
|
||||
|
||||
|
|
@ -275,9 +275,7 @@ async def seed_connector(
|
|||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
space = SearchSpace(
|
||||
name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id
|
||||
)
|
||||
space = Workspace(name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id)
|
||||
session.add(space)
|
||||
await session.flush()
|
||||
space_id = space.id
|
||||
|
|
@ -287,7 +285,7 @@ async def seed_connector(
|
|||
connector_type=connector_type,
|
||||
is_indexable=True,
|
||||
config=config,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
session.add(connector)
|
||||
|
|
@ -297,14 +295,14 @@ async def seed_connector(
|
|||
|
||||
return {
|
||||
"connector_id": connector_id,
|
||||
"search_space_id": space_id,
|
||||
"workspace_id": space_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
async def cleanup_space(async_engine, space_id: int):
|
||||
"""Delete a search space (cascades to connectors/documents)."""
|
||||
"""Delete a workspace (cascades to connectors/documents)."""
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("DELETE FROM searchspaces WHERE id = :sid"), {"sid": space_id}
|
||||
text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ async def composio_calendar(async_engine):
|
|||
name_prefix="cal-composio",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -49,7 +49,7 @@ async def composio_calendar_no_id(async_engine):
|
|||
name_prefix="cal-noid",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -67,7 +67,7 @@ async def native_calendar(async_engine):
|
|||
name_prefix="cal-native",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@patch(_GET_ACCESS_TOKEN)
|
||||
|
|
@ -98,7 +98,7 @@ async def test_composio_calendar_uses_composio_service(
|
|||
await index_google_calendar_events(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ async def test_composio_calendar_without_account_id_returns_error(
|
|||
count, _skipped, error = await index_google_calendar_events(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ async def test_native_calendar_uses_google_calendar_connector(
|
|||
await index_google_calendar_events(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ async def committed_drive_connector(async_engine):
|
|||
name_prefix="drive-composio",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -80,7 +80,7 @@ async def committed_native_drive_connector(async_engine):
|
|||
name_prefix="drive-native",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -92,7 +92,7 @@ async def committed_composio_no_account_id(async_engine):
|
|||
name_prefix="drive-noid",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@patch(_GET_ACCESS_TOKEN)
|
||||
|
|
@ -127,7 +127,7 @@ async def test_composio_drive_indexer_uses_composio_drive_client(
|
|||
await index_google_drive_files(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
folder_id="test-folder-id",
|
||||
)
|
||||
|
|
@ -167,7 +167,7 @@ async def test_composio_connector_without_account_id_returns_error(
|
|||
count, _skipped, error, _unsupported = await index_google_drive_files(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
folder_id="test-folder-id",
|
||||
)
|
||||
|
|
@ -207,7 +207,7 @@ async def test_native_connector_uses_google_drive_client(
|
|||
await index_google_drive_files(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
folder_id="test-folder-id",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ async def composio_gmail(async_engine):
|
|||
name_prefix="gmail-composio",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -49,7 +49,7 @@ async def composio_gmail_no_id(async_engine):
|
|||
name_prefix="gmail-noid",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -67,7 +67,7 @@ async def native_gmail(async_engine):
|
|||
name_prefix="gmail-native",
|
||||
)
|
||||
yield data
|
||||
await cleanup_space(async_engine, data["search_space_id"])
|
||||
await cleanup_space(async_engine, data["workspace_id"])
|
||||
|
||||
|
||||
@patch(_GET_ACCESS_TOKEN)
|
||||
|
|
@ -101,7 +101,7 @@ async def test_composio_gmail_uses_composio_service(
|
|||
await index_google_gmail_messages(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ async def test_composio_gmail_without_account_id_returns_error(
|
|||
count, _skipped, error = await index_google_gmail_messages(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ async def test_native_gmail_uses_google_gmail_connector(
|
|||
await index_google_gmail_messages(
|
||||
session=session,
|
||||
connector_id=data["connector_id"],
|
||||
search_space_id=data["search_space_id"],
|
||||
workspace_id=data["workspace_id"],
|
||||
user_id=data["user_id"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
|
|||
db_session, seed_google_docs
|
||||
):
|
||||
"""Searching with a list of document types returns documents of ALL listed types."""
|
||||
space_id = seed_google_docs["search_space"].id
|
||||
space_id = seed_google_docs["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly report",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
document_type=["GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
|
@ -40,13 +40,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
|
|||
|
||||
async def test_single_string_type_returns_only_that_type(db_session, seed_google_docs):
|
||||
"""Searching with a single string type returns only documents of that exact type."""
|
||||
space_id = seed_google_docs["search_space"].id
|
||||
space_id = seed_google_docs["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly report",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
document_type="GOOGLE_DRIVE_FILE",
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
|
@ -59,13 +59,13 @@ async def test_single_string_type_returns_only_that_type(db_session, seed_google
|
|||
|
||||
async def test_all_invalid_types_returns_empty(db_session, seed_google_docs):
|
||||
"""Searching with a list of nonexistent types returns an empty list, no exceptions."""
|
||||
space_id = seed_google_docs["search_space"].id
|
||||
space_id = seed_google_docs["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly report",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
document_type=["NONEXISTENT_TYPE"],
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ async def test_search_google_drive_includes_legacy_composio_docs(
|
|||
async_engine, committed_google_data, patched_session_factory, patched_embed
|
||||
):
|
||||
"""search_google_drive returns both GOOGLE_DRIVE_FILE and COMPOSIO_GOOGLE_DRIVE_CONNECTOR docs."""
|
||||
space_id = committed_google_data["search_space_id"]
|
||||
space_id = committed_google_data["workspace_id"]
|
||||
|
||||
async with patched_session_factory() as session:
|
||||
service = ConnectorService(session, search_space_id=space_id)
|
||||
service = ConnectorService(session, workspace_id=space_id)
|
||||
_, raw_docs = await service.search_google_drive(
|
||||
user_query="quarterly budget",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
|
|
@ -51,13 +51,13 @@ async def test_search_files_does_not_include_google_types(
|
|||
async_engine, committed_google_data, patched_session_factory, patched_embed
|
||||
):
|
||||
"""search_files returns only FILE docs, not Google Drive docs."""
|
||||
space_id = committed_google_data["search_space_id"]
|
||||
space_id = committed_google_data["workspace_id"]
|
||||
|
||||
async with patched_session_factory() as session:
|
||||
service = ConnectorService(session, search_space_id=space_id)
|
||||
service = ConnectorService(session, workspace_id=space_id)
|
||||
_, raw_docs = await service.search_files(
|
||||
user_query="quarterly budget",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
|
||||
async def test_sets_status_ready(db_session, db_workspace, db_user, mocker):
|
||||
"""Document status is READY after successful indexing."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Hello\n\nSome content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -28,19 +28,19 @@ async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_content_is_source_markdown(db_session, db_search_space, db_user, mocker):
|
||||
async def test_content_is_source_markdown(db_session, db_workspace, db_user, mocker):
|
||||
"""Document content is set to the extracted source markdown."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Hello\n\nSome content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -48,19 +48,19 @@ async def test_content_is_source_markdown(db_session, db_search_space, db_user,
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker):
|
||||
async def test_chunks_written_to_db(db_session, db_workspace, db_user, mocker):
|
||||
"""Chunks derived from the source markdown are persisted in the DB."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Hello\n\nSome content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
|
||||
async def test_raises_on_indexing_failure(db_session, db_search_space, db_user, mocker):
|
||||
async def test_raises_on_indexing_failure(db_session, db_workspace, db_user, mocker):
|
||||
"""RuntimeError is raised when the indexing step fails so the caller can fire a failure notification."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
with pytest.raises(RuntimeError, match=r"Embedding failed|Indexing failed"):
|
||||
|
|
@ -82,7 +82,7 @@ async def test_raises_on_indexing_failure(db_session, db_search_space, db_user,
|
|||
markdown_content="## Hello\n\nSome content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
|
|
@ -93,19 +93,19 @@ async def test_raises_on_indexing_failure(db_session, db_search_space, db_user,
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_updates_content(db_session, db_search_space, db_user, mocker):
|
||||
async def test_reindex_updates_content(db_session, db_workspace, db_user, mocker):
|
||||
"""Document content is updated to the new source markdown after reindexing."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -119,21 +119,19 @@ async def test_reindex_updates_content(db_session, db_search_space, db_user, moc
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_updates_content_hash(
|
||||
db_session, db_search_space, db_user, mocker
|
||||
):
|
||||
async def test_reindex_updates_content_hash(db_session, db_workspace, db_user, mocker):
|
||||
"""Content hash is recomputed after reindexing with new source markdown."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
original_hash = document.content_hash
|
||||
|
|
@ -148,19 +146,19 @@ async def test_reindex_updates_content_hash(
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, mocker):
|
||||
async def test_reindex_sets_status_ready(db_session, db_workspace, db_user, mocker):
|
||||
"""Document status is READY after successful reindexing."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
await adapter.index(
|
||||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -174,7 +172,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
|
|||
|
||||
|
||||
@pytest.mark.usefixtures("patched_embed_texts")
|
||||
async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
|
||||
async def test_reindex_replaces_chunks(db_session, db_workspace, db_user, mocker):
|
||||
"""Reindexing replaces old chunks with new content rather than appending."""
|
||||
mocker.patch(
|
||||
"app.indexing_pipeline.cache.cached_indexing.chunk_text_hybrid",
|
||||
|
|
@ -186,12 +184,12 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
|
|||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
document_id = document.id
|
||||
|
|
@ -212,7 +210,7 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_clears_reindexing_flag(
|
||||
db_session, db_search_space, db_user, mocker
|
||||
db_session, db_workspace, db_user, mocker
|
||||
):
|
||||
"""After successful reindex, content_needs_reindexing is False."""
|
||||
adapter = UploadDocumentAdapter(db_session)
|
||||
|
|
@ -220,12 +218,12 @@ async def test_reindex_clears_reindexing_flag(
|
|||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -241,7 +239,7 @@ async def test_reindex_clears_reindexing_flag(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_raises_on_failure(
|
||||
db_session, db_search_space, db_user, patched_embed_texts, mocker
|
||||
db_session, db_workspace, db_user, patched_embed_texts, mocker
|
||||
):
|
||||
"""RuntimeError is raised when reindexing fails so the caller can handle it."""
|
||||
|
||||
|
|
@ -250,12 +248,12 @@ async def test_reindex_raises_on_failure(
|
|||
markdown_content="## Original\n\nOriginal content.",
|
||||
filename="test.pdf",
|
||||
etl_service="UNSTRUCTURED",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -269,7 +267,7 @@ async def test_reindex_raises_on_failure(
|
|||
|
||||
|
||||
async def test_reindex_raises_on_empty_source_markdown(
|
||||
db_session, db_search_space, db_user, mocker
|
||||
db_session, db_workspace, db_user, mocker
|
||||
):
|
||||
"""Reindexing a document with no source_markdown raises immediately."""
|
||||
from app.db import DocumentType
|
||||
|
|
@ -281,7 +279,7 @@ async def test_reindex_raises_on_empty_source_markdown(
|
|||
content_hash="abc123",
|
||||
unique_identifier_hash="def456",
|
||||
source_markdown="",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=str(db_user.id),
|
||||
)
|
||||
db_session.add(document)
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
def _cal_doc(
|
||||
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
|
||||
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
|
||||
) -> ConnectorDocument:
|
||||
return ConnectorDocument(
|
||||
title=f"Event {unique_id}",
|
||||
source_markdown=f"## Calendar Event\n\nDetails for {unique_id}",
|
||||
unique_id=unique_id,
|
||||
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
metadata={
|
||||
|
|
@ -36,13 +36,13 @@ def _cal_doc(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_calendar_pipeline_creates_ready_document(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A Calendar ConnectorDocument flows through prepare + index to a READY document."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
doc = _cal_doc(
|
||||
unique_id="evt-1",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -54,7 +54,7 @@ async def test_calendar_pipeline_creates_ready_document(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
|
||||
|
|
@ -65,10 +65,10 @@ async def test_calendar_pipeline_creates_ready_document(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_calendar_legacy_doc_migrated(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A legacy Composio Calendar doc is migrated and reused."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
evt_id = "evt-legacy-cal"
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async def test_calendar_legacy_doc_migrated(
|
|||
content_hash=f"ch-{legacy_hash[:12]}",
|
||||
unique_identifier_hash=legacy_hash,
|
||||
source_markdown="## Old event",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
embedding=[0.1] * _EMBEDDING_DIM,
|
||||
status={"state": "ready"},
|
||||
|
|
@ -93,7 +93,7 @@ async def test_calendar_legacy_doc_migrated(
|
|||
|
||||
connector_doc = _cal_doc(
|
||||
unique_id=evt_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
def _drive_doc(
|
||||
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
|
||||
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
|
||||
) -> ConnectorDocument:
|
||||
return ConnectorDocument(
|
||||
title=f"File {unique_id}.pdf",
|
||||
source_markdown=f"## Document Content\n\nText from file {unique_id}",
|
||||
unique_id=unique_id,
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
metadata={
|
||||
|
|
@ -35,13 +35,13 @@ def _drive_doc(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_drive_pipeline_creates_ready_document(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A Drive ConnectorDocument flows through prepare + index to a READY document."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
doc = _drive_doc(
|
||||
unique_id="file-abc",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -53,7 +53,7 @@ async def test_drive_pipeline_creates_ready_document(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
|
||||
|
|
@ -64,10 +64,10 @@ async def test_drive_pipeline_creates_ready_document(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_drive_legacy_doc_migrated(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A legacy Composio Drive doc is migrated and reused."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
file_id = "file-legacy-drive"
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ async def test_drive_legacy_doc_migrated(
|
|||
content_hash=f"ch-{legacy_hash[:12]}",
|
||||
unique_identifier_hash=legacy_hash,
|
||||
source_markdown="## Old file content",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
embedding=[0.1] * _EMBEDDING_DIM,
|
||||
status={"state": "ready"},
|
||||
|
|
@ -92,7 +92,7 @@ async def test_drive_legacy_doc_migrated(
|
|||
|
||||
connector_doc = _drive_doc(
|
||||
unique_id=file_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
@ -114,7 +114,7 @@ async def test_drive_legacy_doc_migrated(
|
|||
|
||||
async def test_should_skip_file_skips_failed_document(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
db_user,
|
||||
):
|
||||
"""A FAILED document with unchanged md5 must be skipped — user can manually retry via Quick Index."""
|
||||
|
|
@ -139,7 +139,7 @@ async def test_should_skip_file_skips_failed_document(
|
|||
if stub:
|
||||
sys.modules.pop(pkg, None)
|
||||
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
file_id = "file-failed-drive"
|
||||
md5 = "abc123deadbeef"
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ async def test_should_skip_file_skips_failed_document(
|
|||
content_hash=f"ch-{doc_hash[:12]}",
|
||||
unique_identifier_hash=doc_hash,
|
||||
source_markdown="## Real content",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=str(db_user.id),
|
||||
embedding=[0.1] * _EMBEDDING_DIM,
|
||||
status=DocumentStatus.failed("LLM rate limit exceeded"),
|
||||
|
|
@ -182,7 +182,7 @@ async def test_should_skip_file_skips_failed_document(
|
|||
@pytest.mark.parametrize("stuck_state", ["pending", "processing"])
|
||||
async def test_should_skip_file_retries_stuck_document(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
db_user,
|
||||
stuck_state,
|
||||
):
|
||||
|
|
@ -208,7 +208,7 @@ async def test_should_skip_file_retries_stuck_document(
|
|||
if stub:
|
||||
sys.modules.pop(pkg, None)
|
||||
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
file_id = f"file-{stuck_state}-drive"
|
||||
md5 = "stuck123checksum"
|
||||
|
||||
|
|
@ -227,7 +227,7 @@ async def test_should_skip_file_retries_stuck_document(
|
|||
content_hash=f"ch-{doc_hash[:12]}",
|
||||
unique_identifier_hash=doc_hash,
|
||||
source_markdown="",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=str(db_user.id),
|
||||
status=status,
|
||||
document_metadata={
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
def _dropbox_doc(
|
||||
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
|
||||
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
|
||||
) -> ConnectorDocument:
|
||||
return ConnectorDocument(
|
||||
title=f"File {unique_id}.docx",
|
||||
source_markdown=f"## Document\n\nContent from {unique_id}",
|
||||
unique_id=unique_id,
|
||||
document_type=DocumentType.DROPBOX_FILE,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
metadata={
|
||||
|
|
@ -34,13 +34,13 @@ def _dropbox_doc(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_dropbox_pipeline_creates_ready_document(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A Dropbox ConnectorDocument flows through prepare + index to a READY document."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
doc = _dropbox_doc(
|
||||
unique_id="db-file-abc",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -52,7 +52,7 @@ async def test_dropbox_pipeline_creates_ready_document(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
|
||||
|
|
@ -63,15 +63,15 @@ async def test_dropbox_pipeline_creates_ready_document(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_dropbox_duplicate_content_skipped(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""Re-indexing a Dropbox doc with the same content is skipped (content hash match)."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
|
||||
doc = _dropbox_doc(
|
||||
unique_id="db-dup-file",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
@ -83,13 +83,13 @@ async def test_dropbox_duplicate_content_skipped(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
first_doc = result.scalars().first()
|
||||
assert first_doc is not None
|
||||
doc2 = _dropbox_doc(
|
||||
unique_id="db-dup-file",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
def _gmail_doc(
|
||||
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
|
||||
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
|
||||
) -> ConnectorDocument:
|
||||
"""Build a Gmail-style ConnectorDocument like the real indexer does."""
|
||||
return ConnectorDocument(
|
||||
|
|
@ -25,7 +25,7 @@ def _gmail_doc(
|
|||
source_markdown=f"## Email\n\nBody of {unique_id}",
|
||||
unique_id=unique_id,
|
||||
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
metadata={
|
||||
|
|
@ -38,13 +38,13 @@ def _gmail_doc(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_gmail_pipeline_creates_ready_document(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A Gmail ConnectorDocument flows through prepare + index to a READY document."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
doc = _gmail_doc(
|
||||
unique_id="msg-pipeline-1",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -56,7 +56,7 @@ async def test_gmail_pipeline_creates_ready_document(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
|
||||
|
|
@ -68,10 +68,10 @@ async def test_gmail_pipeline_creates_ready_document(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_gmail_legacy_doc_migrated_then_reused(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A legacy Composio Gmail doc is migrated then reused by the pipeline."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
msg_id = "msg-legacy-gmail"
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
|
|||
content_hash=f"ch-{legacy_hash[:12]}",
|
||||
unique_identifier_hash=legacy_hash,
|
||||
source_markdown="## Old content",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
embedding=[0.1] * _EMBEDDING_DIM,
|
||||
status={"state": "ready"},
|
||||
|
|
@ -96,7 +96,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
|
|||
|
||||
connector_doc = _gmail_doc(
|
||||
unique_id=msg_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,21 +11,21 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_index_batch_creates_ready_documents(
|
||||
db_session, db_search_space, make_connector_document, mocker
|
||||
db_session, db_workspace, make_connector_document, mocker
|
||||
):
|
||||
"""index_batch prepares and indexes a batch, resulting in READY documents."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
docs = [
|
||||
make_connector_document(
|
||||
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||
unique_id="msg-batch-1",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
source_markdown="## Email 1\n\nBody",
|
||||
),
|
||||
make_connector_document(
|
||||
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||
unique_id="msg-batch-2",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
source_markdown="## Email 2\n\nDifferent body",
|
||||
),
|
||||
]
|
||||
|
|
@ -36,7 +36,7 @@ async def test_index_batch_creates_ready_documents(
|
|||
assert len(results) == 2
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 2
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ pytestmark = pytest.mark.integration
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_sets_status_ready(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Document status is READY after successful indexing."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -38,12 +38,12 @@ async def test_sets_status_ready(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_content_is_source_markdown_by_default(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Document content is set to source_markdown by default."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -63,12 +63,12 @@ async def test_content_is_source_markdown_by_default(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_content_is_source_markdown_when_custom_content(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
):
|
||||
"""Document content is set to source_markdown verbatim."""
|
||||
connector_doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## Raw content",
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -90,12 +90,12 @@ async def test_content_is_source_markdown_when_custom_content(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_chunks_written_to_db(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Chunks derived from source_markdown are persisted in the DB."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -116,12 +116,12 @@ async def test_chunks_written_to_db(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_embedding_written_to_db(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Document embedding vector is persisted in the DB after indexing."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -142,12 +142,12 @@ async def test_embedding_written_to_db(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_updated_at_advances_after_indexing(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""updated_at timestamp is later after indexing than it was at prepare time."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -172,12 +172,12 @@ async def test_updated_at_advances_after_indexing(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_no_llm_falls_back_to_source_markdown(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
):
|
||||
"""Content stays deterministic source markdown without an LLM."""
|
||||
connector_doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## Fallback content",
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -200,12 +200,12 @@ async def test_no_llm_falls_back_to_source_markdown(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_source_markdown_used_without_preview(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
):
|
||||
"""Source markdown is used without fallback preview fields."""
|
||||
connector_doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## Full raw content",
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -227,13 +227,13 @@ async def test_source_markdown_used_without_preview(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_reindex_replaces_old_chunks(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Re-indexing a document replaces its old chunks rather than appending."""
|
||||
connector_doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## v1",
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -245,7 +245,7 @@ async def test_reindex_replaces_old_chunks(
|
|||
await service.index(document, connector_doc)
|
||||
|
||||
updated_doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## v2",
|
||||
)
|
||||
re_prepared = await service.prepare_for_indexing([updated_doc])
|
||||
|
|
@ -262,12 +262,12 @@ async def test_reindex_replaces_old_chunks(
|
|||
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
|
||||
async def test_embedding_error_sets_status_failed(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""Document status is FAILED when embedding raises during indexing."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
@ -287,12 +287,12 @@ async def test_embedding_error_sets_status_failed(
|
|||
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
|
||||
async def test_embedding_error_leaves_no_partial_data(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""A failed indexing attempt leaves no partial embedding or chunks in the DB."""
|
||||
connector_doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
connector_doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
prepared = await service.prepare_for_indexing([connector_doc])
|
||||
|
|
|
|||
|
|
@ -50,14 +50,12 @@ async def _load_chunks(db_session, document_id):
|
|||
@pytest.mark.usefixtures("paragraph_chunker")
|
||||
async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
patched_embed_texts,
|
||||
):
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
doc_v1 = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown=_V1
|
||||
)
|
||||
doc_v1 = make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1)
|
||||
document = await _index(service, doc_v1)
|
||||
|
||||
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
|
||||
|
|
@ -65,7 +63,7 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
|
|||
|
||||
edited = "Intro paragraph.\n\nBody paragraph EDITED.\n\nOutro paragraph."
|
||||
doc_v2 = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown=edited
|
||||
workspace_id=db_workspace.id, source_markdown=edited
|
||||
)
|
||||
await _index(service, doc_v2)
|
||||
|
||||
|
|
@ -94,22 +92,20 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
|
|||
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
|
||||
async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
):
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
document = await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown=_V1
|
||||
),
|
||||
make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
|
||||
)
|
||||
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
|
||||
|
||||
await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="Brand new opener.\n\n" + _V1,
|
||||
),
|
||||
)
|
||||
|
|
@ -130,22 +126,20 @@ async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
|
|||
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
|
||||
async def test_removed_paragraph_is_deleted_and_order_compacts(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
):
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
document = await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown=_V1
|
||||
),
|
||||
make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
|
||||
)
|
||||
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
|
||||
|
||||
await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="Intro paragraph.\n\nOutro paragraph.",
|
||||
),
|
||||
)
|
||||
|
|
@ -162,7 +156,7 @@ async def test_removed_paragraph_is_deleted_and_order_compacts(
|
|||
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
|
||||
async def test_kill_switch_falls_back_to_full_replace(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -171,9 +165,7 @@ async def test_kill_switch_falls_back_to_full_replace(
|
|||
service = IndexingPipelineService(session=db_session)
|
||||
document = await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown=_V1
|
||||
),
|
||||
make_connector_document(workspace_id=db_workspace.id, source_markdown=_V1),
|
||||
)
|
||||
ids_v1 = {c.id for c in await _load_chunks(db_session, document.id)}
|
||||
|
||||
|
|
@ -181,7 +173,7 @@ async def test_kill_switch_falls_back_to_full_replace(
|
|||
await _index(
|
||||
service,
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown=_V1 + "\n\nAppended paragraph.",
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ from app.db import (
|
|||
DocumentType,
|
||||
DocumentVersion,
|
||||
Folder,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -66,7 +66,7 @@ class TestFullIndexer:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""I1: Single new .md file is indexed with status READY."""
|
||||
|
|
@ -76,7 +76,7 @@ class TestFullIndexer:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -90,7 +90,7 @@ class TestFullIndexer:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -106,7 +106,7 @@ class TestFullIndexer:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""I2: Second run on unchanged directory creates no new documents."""
|
||||
|
|
@ -116,7 +116,7 @@ class TestFullIndexer:
|
|||
|
||||
count1, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -125,7 +125,7 @@ class TestFullIndexer:
|
|||
|
||||
count2, _, _, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -139,7 +139,7 @@ class TestFullIndexer:
|
|||
.select_from(Document)
|
||||
.where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -150,7 +150,7 @@ class TestFullIndexer:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""I3: Modified file content triggers re-index and creates a version."""
|
||||
|
|
@ -161,7 +161,7 @@ class TestFullIndexer:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -172,7 +172,7 @@ class TestFullIndexer:
|
|||
|
||||
count, _, _, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -187,7 +187,7 @@ class TestFullIndexer:
|
|||
.join(Document)
|
||||
.where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -201,7 +201,7 @@ class TestFullIndexer:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""I4: Deleted file is removed from DB on re-sync."""
|
||||
|
|
@ -212,7 +212,7 @@ class TestFullIndexer:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -224,7 +224,7 @@ class TestFullIndexer:
|
|||
.select_from(Document)
|
||||
.where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -234,7 +234,7 @@ class TestFullIndexer:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -247,7 +247,7 @@ class TestFullIndexer:
|
|||
.select_from(Document)
|
||||
.where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -258,7 +258,7 @@ class TestFullIndexer:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""I5: Batch mode with a single file only processes that file."""
|
||||
|
|
@ -270,7 +270,7 @@ class TestFullIndexer:
|
|||
|
||||
count, _, _, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -283,7 +283,7 @@ class TestFullIndexer:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -305,7 +305,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F1: First sync creates a root Folder and returns root_folder_id."""
|
||||
|
|
@ -315,7 +315,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -333,7 +333,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F2: Nested dirs create Folder rows with correct parent_id chain."""
|
||||
|
|
@ -348,7 +348,7 @@ class TestFolderMirroring:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -357,7 +357,7 @@ class TestFolderMirroring:
|
|||
folders = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(Folder).where(Folder.search_space_id == db_search_space.id)
|
||||
select(Folder).where(Folder.workspace_id == db_workspace.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
|
|
@ -381,7 +381,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F3: Re-sync reuses existing Folder rows, no duplicates."""
|
||||
|
|
@ -393,7 +393,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -402,7 +402,7 @@ class TestFolderMirroring:
|
|||
folders_before = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(Folder).where(Folder.search_space_id == db_search_space.id)
|
||||
select(Folder).where(Folder.workspace_id == db_workspace.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
|
|
@ -412,7 +412,7 @@ class TestFolderMirroring:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -422,7 +422,7 @@ class TestFolderMirroring:
|
|||
folders_after = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(Folder).where(Folder.search_space_id == db_search_space.id)
|
||||
select(Folder).where(Folder.workspace_id == db_workspace.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
|
|
@ -437,7 +437,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F4: Documents get correct folder_id based on their directory."""
|
||||
|
|
@ -450,7 +450,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -461,7 +461,7 @@ class TestFolderMirroring:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -485,7 +485,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F5: Deleted dir's empty Folder row is cleaned up on re-sync."""
|
||||
|
|
@ -502,7 +502,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -517,7 +517,7 @@ class TestFolderMirroring:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -539,7 +539,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F6: Single-file mode creates missing Folder rows and assigns correct folder_id."""
|
||||
|
|
@ -549,7 +549,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -561,7 +561,7 @@ class TestFolderMirroring:
|
|||
|
||||
count, _, _, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -597,7 +597,7 @@ class TestFolderMirroring:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""F7: Deleting the only file in a subfolder via batch mode removes empty Folder rows."""
|
||||
|
|
@ -610,7 +610,7 @@ class TestFolderMirroring:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -626,7 +626,7 @@ class TestFolderMirroring:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -656,7 +656,7 @@ class TestBatchMode:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
patched_batch_sessions,
|
||||
):
|
||||
|
|
@ -669,7 +669,7 @@ class TestBatchMode:
|
|||
|
||||
count, failed, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -689,7 +689,7 @@ class TestBatchMode:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -707,7 +707,7 @@ class TestBatchMode:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
patched_batch_sessions,
|
||||
):
|
||||
|
|
@ -720,7 +720,7 @@ class TestBatchMode:
|
|||
|
||||
count, failed, _, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -740,7 +740,7 @@ class TestBatchMode:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -762,7 +762,7 @@ class TestPipelineIntegration:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
mocker,
|
||||
):
|
||||
"""P1: LOCAL_FOLDER_FILE ConnectorDocument through prepare+index to READY."""
|
||||
|
|
@ -776,7 +776,7 @@ class TestPipelineIntegration:
|
|||
source_markdown="## Local file\n\nContent from disk.",
|
||||
unique_id="test-folder:test.md",
|
||||
document_type=DocumentType.LOCAL_FOLDER_FILE,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
connector_id=None,
|
||||
created_by_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -794,7 +794,7 @@ class TestPipelineIntegration:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -816,7 +816,7 @@ class TestDirectConvert:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""DC1: CSV file is indexed as a markdown table, not raw comma-separated text."""
|
||||
|
|
@ -826,7 +826,7 @@ class TestDirectConvert:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -839,7 +839,7 @@ class TestDirectConvert:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -853,7 +853,7 @@ class TestDirectConvert:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""DC2: TSV file is indexed as a markdown table."""
|
||||
|
|
@ -865,7 +865,7 @@ class TestDirectConvert:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -878,7 +878,7 @@ class TestDirectConvert:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -891,7 +891,7 @@ class TestDirectConvert:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""DC3: HTML file is indexed as clean markdown, not raw HTML."""
|
||||
|
|
@ -901,7 +901,7 @@ class TestDirectConvert:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -914,7 +914,7 @@ class TestDirectConvert:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -927,7 +927,7 @@ class TestDirectConvert:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""DC4: CSV via single-file batch mode also produces a markdown table."""
|
||||
|
|
@ -937,7 +937,7 @@ class TestDirectConvert:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -951,7 +951,7 @@ class TestDirectConvert:
|
|||
await db_session.execute(
|
||||
select(Document).where(
|
||||
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
|
||||
Document.search_space_id == db_search_space.id,
|
||||
Document.workspace_id == db_workspace.id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
|
@ -984,7 +984,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""CR1: Successful full-scan sync debits user.credit_micros_balance."""
|
||||
|
|
@ -998,7 +998,7 @@ class TestEtlCredits:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1017,7 +1017,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""CR2: Full-scan skips file when the wallet is empty."""
|
||||
|
|
@ -1030,7 +1030,7 @@ class TestEtlCredits:
|
|||
|
||||
count, _skipped, _root_folder_id, _err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1048,7 +1048,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""CR3: Single-file mode debits balance on success."""
|
||||
|
|
@ -1062,7 +1062,7 @@ class TestEtlCredits:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1082,7 +1082,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""CR4: Single-file mode skips file when the wallet is empty."""
|
||||
|
|
@ -1095,7 +1095,7 @@ class TestEtlCredits:
|
|||
|
||||
count, _skipped, _root_folder_id, err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1116,7 +1116,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""CR5: Re-syncing an unchanged file does not consume additional credit."""
|
||||
|
|
@ -1129,7 +1129,7 @@ class TestEtlCredits:
|
|||
|
||||
count1, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1142,7 +1142,7 @@ class TestEtlCredits:
|
|||
|
||||
count2, _, _, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1160,7 +1160,7 @@ class TestEtlCredits:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
patched_batch_sessions,
|
||||
):
|
||||
|
|
@ -1177,7 +1177,7 @@ class TestEtlCredits:
|
|||
|
||||
count, failed, _root_folder_id, _err = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1209,7 +1209,7 @@ class TestIndexingProgressFlag:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""IP1: Full-scan mode clears indexing_in_progress after completion."""
|
||||
|
|
@ -1219,7 +1219,7 @@ class TestIndexingProgressFlag:
|
|||
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1237,7 +1237,7 @@ class TestIndexingProgressFlag:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""IP2: Single-file (Chokidar) mode clears indexing_in_progress after completion."""
|
||||
|
|
@ -1246,7 +1246,7 @@ class TestIndexingProgressFlag:
|
|||
(tmp_path / "root.md").write_text("root")
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1256,7 +1256,7 @@ class TestIndexingProgressFlag:
|
|||
|
||||
await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
@ -1275,7 +1275,7 @@ class TestIndexingProgressFlag:
|
|||
self,
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""IP3: indexing_in_progress is True on the root folder while indexing is running."""
|
||||
|
|
@ -1294,7 +1294,7 @@ class TestIndexingProgressFlag:
|
|||
folder = (
|
||||
await db_session.execute(
|
||||
select(Folder).where(
|
||||
Folder.search_space_id == db_search_space.id,
|
||||
Folder.workspace_id == db_workspace.id,
|
||||
Folder.parent_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
|
@ -1308,7 +1308,7 @@ class TestIndexingProgressFlag:
|
|||
try:
|
||||
_, _, root_folder_id, _ = await index_local_folder(
|
||||
session=db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user_id=str(db_user.id),
|
||||
folder_path=str(tmp_path),
|
||||
folder_name="test-folder",
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ pytestmark = pytest.mark.integration
|
|||
async def _make_doc(
|
||||
db_session,
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
connector_id: int,
|
||||
user_id: str,
|
||||
file_id: str,
|
||||
status: dict,
|
||||
) -> Document:
|
||||
uid_hash = compute_identifier_hash(
|
||||
DocumentType.GOOGLE_DRIVE_FILE.value, file_id, search_space_id
|
||||
DocumentType.GOOGLE_DRIVE_FILE.value, file_id, workspace_id
|
||||
)
|
||||
doc = Document(
|
||||
title=f"{file_id}.pdf",
|
||||
|
|
@ -36,7 +36,7 @@ async def _make_doc(
|
|||
content_hash=hashlib.sha256(f"placeholder:{uid_hash}".encode()).hexdigest(),
|
||||
unique_identifier_hash=uid_hash,
|
||||
document_metadata={"google_drive_file_id": file_id},
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
status=status,
|
||||
|
|
@ -47,11 +47,11 @@ async def _make_doc(
|
|||
|
||||
|
||||
async def test_pending_placeholder_marked_failed(
|
||||
db_session, db_search_space, db_connector, db_user
|
||||
db_session, db_workspace, db_connector, db_user
|
||||
):
|
||||
doc = await _make_doc(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
file_id="file-pending",
|
||||
|
|
@ -61,7 +61,7 @@ async def test_pending_placeholder_marked_failed(
|
|||
marked = await mark_connector_documents_failed(
|
||||
db_session,
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
failures=[("file-pending", "Download/ETL failed: boom")],
|
||||
)
|
||||
|
||||
|
|
@ -72,11 +72,11 @@ async def test_pending_placeholder_marked_failed(
|
|||
|
||||
|
||||
async def test_ready_document_not_clobbered(
|
||||
db_session, db_search_space, db_connector, db_user
|
||||
db_session, db_workspace, db_connector, db_user
|
||||
):
|
||||
doc = await _make_doc(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
file_id="file-ready",
|
||||
|
|
@ -86,7 +86,7 @@ async def test_ready_document_not_clobbered(
|
|||
marked = await mark_connector_documents_failed(
|
||||
db_session,
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
failures=[("file-ready", "should be ignored")],
|
||||
)
|
||||
|
||||
|
|
@ -95,16 +95,16 @@ async def test_ready_document_not_clobbered(
|
|||
assert DocumentStatus.is_state(doc.status, DocumentStatus.READY)
|
||||
|
||||
|
||||
async def test_missing_document_is_noop(db_session, db_search_space):
|
||||
async def test_missing_document_is_noop(db_session, db_workspace):
|
||||
marked = await mark_connector_documents_failed(
|
||||
db_session,
|
||||
document_type=DocumentType.GOOGLE_DRIVE_FILE,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
failures=[("does-not-exist", "reason")],
|
||||
)
|
||||
|
||||
assert marked == 0
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
assert result.scalars().first() is None
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
async def test_legacy_composio_gmail_doc_migrated_in_db(
|
||||
db_session, db_search_space, db_user, make_connector_document
|
||||
db_session, db_workspace, db_user, make_connector_document
|
||||
):
|
||||
"""A Composio Gmail doc in the DB gets its hash and type updated to native."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
unique_id = "msg-legacy-123"
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
|
|||
content="legacy content",
|
||||
content_hash=f"ch-{legacy_hash[:12]}",
|
||||
unique_identifier_hash=legacy_hash,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
embedding=[0.1] * _EMBEDDING_DIM,
|
||||
status={"state": "ready"},
|
||||
|
|
@ -46,7 +46,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
|
|||
connector_doc = make_connector_document(
|
||||
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
|
||||
unique_id=unique_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
)
|
||||
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -59,33 +59,31 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
|
|||
assert reloaded.document_type == DocumentType.GOOGLE_GMAIL_CONNECTOR
|
||||
|
||||
|
||||
async def test_no_legacy_doc_is_noop(
|
||||
db_session, db_search_space, make_connector_document
|
||||
):
|
||||
async def test_no_legacy_doc_is_noop(db_session, db_workspace, make_connector_document):
|
||||
"""When no legacy document exists, migrate_legacy_docs does nothing."""
|
||||
connector_doc = make_connector_document(
|
||||
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
|
||||
unique_id="evt-no-legacy",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
await service.migrate_legacy_docs([connector_doc])
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
assert result.scalars().all() == []
|
||||
|
||||
|
||||
async def test_non_google_type_is_skipped(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""migrate_legacy_docs skips ConnectorDocuments that are not Google types."""
|
||||
connector_doc = make_connector_document(
|
||||
document_type=DocumentType.CLICKUP_CONNECTOR,
|
||||
unique_id="task-1",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
def _onedrive_doc(
|
||||
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
|
||||
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
|
||||
) -> ConnectorDocument:
|
||||
return ConnectorDocument(
|
||||
title=f"File {unique_id}.docx",
|
||||
source_markdown=f"## Document\n\nContent from {unique_id}",
|
||||
unique_id=unique_id,
|
||||
document_type=DocumentType.ONEDRIVE_FILE,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
connector_id=connector_id,
|
||||
created_by_id=user_id,
|
||||
metadata={
|
||||
|
|
@ -34,13 +34,13 @@ def _onedrive_doc(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_onedrive_pipeline_creates_ready_document(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""A OneDrive ConnectorDocument flows through prepare + index to a READY document."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
doc = _onedrive_doc(
|
||||
unique_id="od-file-abc",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=str(db_user.id),
|
||||
)
|
||||
|
|
@ -52,7 +52,7 @@ async def test_onedrive_pipeline_creates_ready_document(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
|
||||
|
|
@ -63,15 +63,15 @@ async def test_onedrive_pipeline_creates_ready_document(
|
|||
|
||||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_onedrive_duplicate_content_skipped(
|
||||
db_session, db_search_space, db_connector, db_user, mocker
|
||||
db_session, db_workspace, db_connector, db_user, mocker
|
||||
):
|
||||
"""Re-indexing a OneDrive doc with the same content is skipped (content hash match)."""
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
user_id = str(db_user.id)
|
||||
|
||||
doc = _onedrive_doc(
|
||||
unique_id="od-dup-file",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
@ -83,13 +83,13 @@ async def test_onedrive_duplicate_content_skipped(
|
|||
await service.index(prepared[0], doc)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == space_id)
|
||||
select(Document).filter(Document.workspace_id == space_id)
|
||||
)
|
||||
first_doc = result.scalars().first()
|
||||
assert first_doc is not None
|
||||
doc2 = _onedrive_doc(
|
||||
unique_id="od-dup-file",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
connector_id=db_connector.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
async def test_new_document_is_persisted_with_pending_status(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""A new document is created in the DB with PENDING status and correct markdown."""
|
||||
doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
results = await service.prepare_for_indexing([doc])
|
||||
|
|
@ -35,12 +35,12 @@ async def test_new_document_is_persisted_with_pending_status(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_unchanged_ready_document_is_skipped(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""A READY document with unchanged content is not returned for re-indexing."""
|
||||
doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
# Index fully so the document reaches ready state
|
||||
|
|
@ -56,13 +56,13 @@ async def test_unchanged_ready_document_is_skipped(
|
|||
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
|
||||
async def test_title_only_change_updates_title_in_db(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""A title-only change updates the DB title without re-queuing the document."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id, title="Original Title"
|
||||
workspace_id=db_workspace.id, title="Original Title"
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ async def test_title_only_change_updates_title_in_db(
|
|||
await service.index(prepared[0], original)
|
||||
|
||||
renamed = make_connector_document(
|
||||
search_space_id=db_search_space.id, title="Updated Title"
|
||||
workspace_id=db_workspace.id, title="Updated Title"
|
||||
)
|
||||
results = await service.prepare_for_indexing([renamed])
|
||||
|
||||
|
|
@ -86,11 +86,11 @@ async def test_title_only_change_updates_title_in_db(
|
|||
|
||||
|
||||
async def test_changed_content_is_returned_for_reprocessing(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""A document with changed content is returned for re-indexing with updated markdown."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown="## v1"
|
||||
workspace_id=db_workspace.id, source_markdown="## v1"
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ async def test_changed_content_is_returned_for_reprocessing(
|
|||
original_id = first[0].id
|
||||
|
||||
updated = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown="## v2"
|
||||
workspace_id=db_workspace.id, source_markdown="## v2"
|
||||
)
|
||||
results = await service.prepare_for_indexing([updated])
|
||||
|
||||
|
|
@ -115,24 +115,24 @@ async def test_changed_content_is_returned_for_reprocessing(
|
|||
|
||||
|
||||
async def test_all_documents_in_batch_are_persisted(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""All documents in a batch are persisted and returned."""
|
||||
docs = [
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="id-1",
|
||||
title="Doc 1",
|
||||
source_markdown="## Content 1",
|
||||
),
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="id-2",
|
||||
title="Doc 2",
|
||||
source_markdown="## Content 2",
|
||||
),
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="id-3",
|
||||
title="Doc 3",
|
||||
source_markdown="## Content 3",
|
||||
|
|
@ -145,7 +145,7 @@ async def test_all_documents_in_batch_are_persisted(
|
|||
assert len(results) == 3
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ async def test_all_documents_in_batch_are_persisted(
|
|||
|
||||
|
||||
async def test_duplicate_in_batch_is_persisted_once(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""The same document passed twice in a batch is only persisted once."""
|
||||
doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
results = await service.prepare_for_indexing([doc, doc])
|
||||
|
|
@ -164,7 +164,7 @@ async def test_duplicate_in_batch_is_persisted_once(
|
|||
assert len(results) == 1
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
|
|
@ -172,11 +172,11 @@ async def test_duplicate_in_batch_is_persisted_once(
|
|||
|
||||
|
||||
async def test_created_by_id_is_persisted(
|
||||
db_session, db_user, db_search_space, make_connector_document
|
||||
db_session, db_user, db_workspace, make_connector_document
|
||||
):
|
||||
"""created_by_id from the connector document is persisted on the DB row."""
|
||||
doc = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=str(db_user.id),
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
|
@ -193,11 +193,11 @@ async def test_created_by_id_is_persisted(
|
|||
|
||||
|
||||
async def test_metadata_is_updated_when_content_changes(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""document_metadata is overwritten with the latest metadata when content changes."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## v1",
|
||||
metadata={"status": "in_progress"},
|
||||
)
|
||||
|
|
@ -207,7 +207,7 @@ async def test_metadata_is_updated_when_content_changes(
|
|||
document_id = first[0].id
|
||||
|
||||
updated = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
source_markdown="## v2",
|
||||
metadata={"status": "done"},
|
||||
)
|
||||
|
|
@ -222,12 +222,10 @@ async def test_metadata_is_updated_when_content_changes(
|
|||
|
||||
|
||||
async def test_updated_at_advances_when_title_only_changes(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""updated_at advances even when only the title changes."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id, title="Old Title"
|
||||
)
|
||||
original = make_connector_document(workspace_id=db_workspace.id, title="Old Title")
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
first = await service.prepare_for_indexing([original])
|
||||
|
|
@ -238,9 +236,7 @@ async def test_updated_at_advances_when_title_only_changes(
|
|||
)
|
||||
updated_at_v1 = result.scalars().first().updated_at
|
||||
|
||||
renamed = make_connector_document(
|
||||
search_space_id=db_search_space.id, title="New Title"
|
||||
)
|
||||
renamed = make_connector_document(workspace_id=db_workspace.id, title="New Title")
|
||||
await service.prepare_for_indexing([renamed])
|
||||
|
||||
result = await db_session.execute(
|
||||
|
|
@ -252,11 +248,11 @@ async def test_updated_at_advances_when_title_only_changes(
|
|||
|
||||
|
||||
async def test_updated_at_advances_when_content_changes(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""updated_at advances when document content changes."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown="## v1"
|
||||
workspace_id=db_workspace.id, source_markdown="## v1"
|
||||
)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
|
|
@ -269,7 +265,7 @@ async def test_updated_at_advances_when_content_changes(
|
|||
updated_at_v1 = result.scalars().first().updated_at
|
||||
|
||||
updated = make_connector_document(
|
||||
search_space_id=db_search_space.id, source_markdown="## v2"
|
||||
workspace_id=db_workspace.id, source_markdown="## v2"
|
||||
)
|
||||
await service.prepare_for_indexing([updated])
|
||||
|
||||
|
|
@ -282,16 +278,16 @@ async def test_updated_at_advances_when_content_changes(
|
|||
|
||||
|
||||
async def test_same_content_from_different_source_skipped_in_single_batch(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""Two documents with identical content in the same batch result in only one being persisted."""
|
||||
first = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="source-a",
|
||||
source_markdown="## Shared content",
|
||||
)
|
||||
second = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="source-b",
|
||||
source_markdown="## Shared content",
|
||||
)
|
||||
|
|
@ -302,22 +298,22 @@ async def test_same_content_from_different_source_skipped_in_single_batch(
|
|||
assert len(results) == 1
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
assert len(result.scalars().all()) == 1
|
||||
|
||||
|
||||
async def test_same_content_from_different_source_is_skipped(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""A document with content identical to an already-indexed document is skipped."""
|
||||
first = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="source-a",
|
||||
source_markdown="## Shared content",
|
||||
)
|
||||
second = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="source-b",
|
||||
source_markdown="## Shared content",
|
||||
)
|
||||
|
|
@ -329,7 +325,7 @@ async def test_same_content_from_different_source_is_skipped(
|
|||
assert results == []
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
assert len(result.scalars().all()) == 1
|
||||
|
||||
|
|
@ -337,12 +333,12 @@ async def test_same_content_from_different_source_is_skipped(
|
|||
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
|
||||
async def test_failed_document_with_unchanged_content_is_requeued(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
mocker,
|
||||
):
|
||||
"""A FAILED document with unchanged content is re-queued as PENDING on the next run."""
|
||||
doc = make_connector_document(search_space_id=db_search_space.id)
|
||||
doc = make_connector_document(workspace_id=db_workspace.id)
|
||||
service = IndexingPipelineService(session=db_session)
|
||||
|
||||
# First run: document is created and indexing crashes, so status becomes failed.
|
||||
|
|
@ -372,11 +368,11 @@ async def test_failed_document_with_unchanged_content_is_requeued(
|
|||
|
||||
|
||||
async def test_title_and_content_change_updates_both_and_returns_document(
|
||||
db_session, db_search_space, make_connector_document
|
||||
db_session, db_workspace, make_connector_document
|
||||
):
|
||||
"""When both title and content change, both are updated and the document is returned for re-indexing."""
|
||||
original = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Original Title",
|
||||
source_markdown="## v1",
|
||||
)
|
||||
|
|
@ -386,7 +382,7 @@ async def test_title_and_content_change_updates_both_and_returns_document(
|
|||
original_id = first[0].id
|
||||
|
||||
updated = make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
title="Updated Title",
|
||||
source_markdown="## v2",
|
||||
)
|
||||
|
|
@ -406,7 +402,7 @@ async def test_title_and_content_change_updates_both_and_returns_document(
|
|||
|
||||
async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_persisted(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_connector_document,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -416,17 +412,17 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
|
|||
"""
|
||||
docs = [
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="good-1",
|
||||
source_markdown="## Good doc 1",
|
||||
),
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="will-fail",
|
||||
source_markdown="## Bad doc",
|
||||
),
|
||||
make_connector_document(
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
unique_id="good-2",
|
||||
source_markdown="## Good doc 2",
|
||||
),
|
||||
|
|
@ -448,6 +444,6 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
|
|||
assert len(results) == 2
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Document).filter(Document.search_space_id == db_search_space.id)
|
||||
select(Document).filter(Document.workspace_id == db_workspace.id)
|
||||
)
|
||||
assert len(result.scalars().all()) == 2
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Behavior guard for the shared find/upsert/update logic (BaseNotificationHandler).
|
||||
|
||||
Uses the connector-indexing handler instance to drive the base methods against
|
||||
real Postgres, pinning upsert dedup, search-space scoping, and status stamping.
|
||||
real Postgres, pinning upsert dedup, workspace scoping, and status stamping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,7 +10,7 @@ import pytest
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.persistence import Notification
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ handler = NotificationService.connector_indexing
|
|||
async def test_find_or_create_creates_with_progress_metadata(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Creating a notification seeds operation id, in-progress status, and start time."""
|
||||
notification = await handler.find_or_create_notification(
|
||||
|
|
@ -31,7 +31,7 @@ async def test_find_or_create_creates_with_progress_metadata(
|
|||
operation_id="op-create",
|
||||
title="Title",
|
||||
message="Message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
assert notification.notification_metadata["operation_id"] == "op-create"
|
||||
|
|
@ -42,7 +42,7 @@ async def test_find_or_create_creates_with_progress_metadata(
|
|||
async def test_find_or_create_upserts_same_operation(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Reusing an operation id updates the same row instead of creating a duplicate."""
|
||||
first = await handler.find_or_create_notification(
|
||||
|
|
@ -51,7 +51,7 @@ async def test_find_or_create_upserts_same_operation(
|
|||
operation_id="op-upsert",
|
||||
title="First",
|
||||
message="First message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
second = await handler.find_or_create_notification(
|
||||
|
|
@ -60,7 +60,7 @@ async def test_find_or_create_upserts_same_operation(
|
|||
operation_id="op-upsert",
|
||||
title="Second",
|
||||
message="Second message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
assert second.id == first.id
|
||||
|
|
@ -76,22 +76,22 @@ async def test_find_or_create_upserts_same_operation(
|
|||
assert count == 1
|
||||
|
||||
|
||||
async def test_find_by_operation_is_scoped_to_search_space(
|
||||
async def test_find_by_operation_is_scoped_to_workspace(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Operation-id lookup is scoped per search space, so other spaces don't match."""
|
||||
"""Operation-id lookup is scoped per workspace, so other spaces don't match."""
|
||||
await handler.find_or_create_notification(
|
||||
session=db_session,
|
||||
user_id=db_user.id,
|
||||
operation_id="op-scoped",
|
||||
title="Title",
|
||||
message="Message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
other_space = SearchSpace(name="Other Space", user_id=db_user.id)
|
||||
other_space = Workspace(name="Other Space", user_id=db_user.id)
|
||||
db_session.add(other_space)
|
||||
await db_session.flush()
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
|
|||
session=db_session,
|
||||
user_id=db_user.id,
|
||||
operation_id="op-scoped",
|
||||
search_space_id=other_space.id,
|
||||
workspace_id=other_space.id,
|
||||
)
|
||||
assert found_other is None
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
|
|||
session=db_session,
|
||||
user_id=db_user.id,
|
||||
operation_id="op-scoped",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
assert found_same is not None
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
|
|||
async def test_update_notification_completed_stamps_completed_at(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Completing a notification stamps completed_at and merges metadata updates."""
|
||||
notification = await handler.find_or_create_notification(
|
||||
|
|
@ -124,7 +124,7 @@ async def test_update_notification_completed_stamps_completed_at(
|
|||
operation_id="op-complete",
|
||||
title="Title",
|
||||
message="Message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
updated = await handler.update_notification(
|
||||
|
|
@ -142,7 +142,7 @@ async def test_update_notification_completed_stamps_completed_at(
|
|||
async def test_update_notification_failed_stamps_completed_at(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Failing a notification also stamps completed_at for the terminal state."""
|
||||
notification = await handler.find_or_create_notification(
|
||||
|
|
@ -151,7 +151,7 @@ async def test_update_notification_failed_stamps_completed_at(
|
|||
operation_id="op-fail",
|
||||
title="Title",
|
||||
message="Message",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
updated = await handler.update_notification(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
|
|||
handler = NotificationService.comment_reply
|
||||
|
||||
|
||||
async def _notify(db_session, db_user, db_search_space, *, reply_id=1, preview="hi"):
|
||||
async def _notify(db_session, db_user, db_workspace, *, reply_id=1, preview="hi"):
|
||||
"""Raise a comment-reply notification for the assertions in the tests below."""
|
||||
return await handler.notify_comment_reply(
|
||||
session=db_session,
|
||||
|
|
@ -28,15 +28,15 @@ async def _notify(db_session, db_user, db_search_space, *, reply_id=1, preview="
|
|||
author_avatar_url=None,
|
||||
author_email="bob@surfsense.net",
|
||||
content_preview=preview,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
|
||||
async def test_comment_reply_title_and_message(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A reply notification names the author and carries the comment preview."""
|
||||
notification = await _notify(db_session, db_user, db_search_space, preview="thanks")
|
||||
notification = await _notify(db_session, db_user, db_workspace, preview="thanks")
|
||||
|
||||
assert notification.type == "comment_reply"
|
||||
assert notification.title == "Bob replied in a thread"
|
||||
|
|
@ -44,21 +44,19 @@ async def test_comment_reply_title_and_message(
|
|||
|
||||
|
||||
async def test_comment_reply_truncates_long_preview(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A long comment preview is truncated in the reply message."""
|
||||
notification = await _notify(
|
||||
db_session, db_user, db_search_space, preview="y" * 150
|
||||
)
|
||||
notification = await _notify(db_session, db_user, db_workspace, preview="y" * 150)
|
||||
|
||||
assert notification.message == "y" * 100 + "..."
|
||||
|
||||
|
||||
async def test_comment_reply_is_idempotent(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Re-notifying the same reply id reuses the existing notification row."""
|
||||
first = await _notify(db_session, db_user, db_search_space, reply_id=5)
|
||||
second = await _notify(db_session, db_user, db_search_space, reply_id=5)
|
||||
first = await _notify(db_session, db_user, db_workspace, reply_id=5)
|
||||
second = await _notify(db_session, db_user, db_workspace, reply_id=5)
|
||||
|
||||
assert second.id == first.id
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -19,7 +19,7 @@ pytestmark = pytest.mark.integration
|
|||
async def test_indexing_started_opens_notification(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Starting indexing opens an unread notification with connecting-stage metadata."""
|
||||
notification = await NotificationService.connector_indexing.notify_indexing_started(
|
||||
|
|
@ -28,7 +28,7 @@ async def test_indexing_started_opens_notification(
|
|||
connector_id=42,
|
||||
connector_name="Notion - My Workspace",
|
||||
connector_type="NOTION_CONNECTOR",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
assert notification.id is not None
|
||||
|
|
@ -50,7 +50,7 @@ async def test_indexing_started_opens_notification(
|
|||
async def _started(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
*,
|
||||
connector_name: str = "Notion - My Workspace",
|
||||
):
|
||||
|
|
@ -61,17 +61,17 @@ async def _started(
|
|||
connector_id=42,
|
||||
connector_name=connector_name,
|
||||
connector_type="NOTION_CONNECTOR",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
|
||||
async def test_indexing_progress_reports_stage_and_percent(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Progress updates surface the stage message and compute a percent complete."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
updated = await NotificationService.connector_indexing.notify_indexing_progress(
|
||||
session=db_session,
|
||||
|
|
@ -93,10 +93,10 @@ async def test_indexing_progress_reports_stage_and_percent(
|
|||
async def test_indexing_completed_clean_success(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""A clean multi-file sync reports ready/completed with plural wording."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=db_session,
|
||||
|
|
@ -113,10 +113,10 @@ async def test_indexing_completed_clean_success(
|
|||
async def test_indexing_completed_singular_file(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""A single synced file uses singular 'file' wording."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=db_session,
|
||||
|
|
@ -130,10 +130,10 @@ async def test_indexing_completed_singular_file(
|
|||
async def test_indexing_completed_nothing_to_sync(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""Completing with nothing new reports 'Already up to date!'."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=db_session,
|
||||
|
|
@ -149,10 +149,10 @@ async def test_indexing_completed_nothing_to_sync(
|
|||
async def test_indexing_completed_hard_failure(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""An error with nothing synced reports a hard failure."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=db_session,
|
||||
|
|
@ -170,10 +170,10 @@ async def test_indexing_completed_hard_failure(
|
|||
async def test_indexing_completed_partial_with_error_note(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""An error after partial progress still completes, with an appended note."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=db_session,
|
||||
|
|
@ -190,10 +190,10 @@ async def test_indexing_completed_partial_with_error_note(
|
|||
async def test_retry_progress_frames_delay_as_providers(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""A retry message frames the delay as the provider's, using its short name."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
retry = await NotificationService.connector_indexing.notify_retry_progress(
|
||||
session=db_session,
|
||||
|
|
@ -214,10 +214,10 @@ async def test_retry_progress_frames_delay_as_providers(
|
|||
async def test_retry_progress_shows_wait_and_synced_count(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
"""A retry surfaces the wait time and how many items synced so far."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
retry = await NotificationService.connector_indexing.notify_retry_progress(
|
||||
session=db_session,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -13,22 +13,22 @@ pytestmark = pytest.mark.integration
|
|||
handler = NotificationService.document_processing
|
||||
|
||||
|
||||
async def _started(db_session, db_user, db_search_space, *, name="report.pdf"):
|
||||
async def _started(db_session, db_user, db_workspace, *, name="report.pdf"):
|
||||
"""Open a document-processing notification to update in the tests below."""
|
||||
return await handler.notify_processing_started(
|
||||
session=db_session,
|
||||
user_id=db_user.id,
|
||||
document_type="FILE",
|
||||
document_name=name,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
|
||||
async def test_processing_started_queues(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Starting processing queues a notification in the 'queued' stage."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
assert notification.type == "document_processing"
|
||||
assert notification.title == "Processing: report.pdf"
|
||||
|
|
@ -37,10 +37,10 @@ async def test_processing_started_queues(
|
|||
|
||||
|
||||
async def test_processing_progress_maps_stage(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A progress update maps the stage to its user-facing message."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
updated = await handler.notify_processing_progress(
|
||||
session=db_session, notification=notification, stage="parsing"
|
||||
|
|
@ -51,10 +51,10 @@ async def test_processing_progress_maps_stage(
|
|||
|
||||
|
||||
async def test_processing_completed_success(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Successful processing reports ready/searchable and a completed status."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await handler.notify_processing_completed(
|
||||
session=db_session, notification=notification, document_id=99
|
||||
|
|
@ -66,10 +66,10 @@ async def test_processing_completed_success(
|
|||
|
||||
|
||||
async def test_processing_completed_failure(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Failed processing reports a failed status with the error in the message."""
|
||||
notification = await _started(db_session, db_user, db_search_space)
|
||||
notification = await _started(db_session, db_user, db_workspace)
|
||||
|
||||
done = await handler.notify_processing_completed(
|
||||
session=db_session, notification=notification, error_message="bad file"
|
||||
|
|
@ -81,7 +81,7 @@ async def test_processing_completed_failure(
|
|||
|
||||
|
||||
async def test_processing_started_truncates_long_filename(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A long filename is truncated in the title but kept in metadata."""
|
||||
long_name = "a" * 250
|
||||
|
|
@ -91,7 +91,7 @@ async def test_processing_started_truncates_long_filename(
|
|||
user_id=db_user.id,
|
||||
document_type="FILE",
|
||||
document_name=long_name,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
assert len(notification.title) <= 200
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ async def _seed(
|
|||
title: str = "Title",
|
||||
message: str = "Message",
|
||||
read: bool = False,
|
||||
search_space_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
metadata: dict | None = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> Notification:
|
||||
"""Insert a notification row directly for the API tests to read back."""
|
||||
notification = Notification(
|
||||
user_id=user.id,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
type=type,
|
||||
title=title,
|
||||
message=message,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -14,7 +14,7 @@ handler = NotificationService.insufficient_credits
|
|||
|
||||
|
||||
async def test_insufficient_credits_message_and_action(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""An insufficient-credits notification states cost and carries a buy-credits link."""
|
||||
notification = await handler.notify_insufficient_credits(
|
||||
|
|
@ -22,7 +22,7 @@ async def test_insufficient_credits_message_and_action(
|
|||
user_id=db_user.id,
|
||||
document_name="short.pdf",
|
||||
document_type="FILE",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
balance_micros=250_000,
|
||||
required_micros=1_000_000,
|
||||
)
|
||||
|
|
@ -36,12 +36,12 @@ async def test_insufficient_credits_message_and_action(
|
|||
assert notification.notification_metadata["status"] == "failed"
|
||||
assert notification.notification_metadata["action_label"] == "Buy credits"
|
||||
assert notification.notification_metadata["action_url"] == (
|
||||
f"/dashboard/{db_search_space.id}/buy-more"
|
||||
f"/dashboard/{db_workspace.id}/buy-more"
|
||||
)
|
||||
|
||||
|
||||
async def test_insufficient_credits_truncates_long_name(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A long document name is truncated in the notification title."""
|
||||
long_name = "a" * 50
|
||||
|
|
@ -51,7 +51,7 @@ async def test_insufficient_credits_truncates_long_name(
|
|||
user_id=db_user.id,
|
||||
document_name=long_name,
|
||||
document_type="FILE",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
balance_micros=250_000,
|
||||
required_micros=1_000_000,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SearchSpace, User
|
||||
from app.db import User, Workspace
|
||||
from app.notifications.service import NotificationService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
|
|||
handler = NotificationService.mention
|
||||
|
||||
|
||||
async def _notify(db_session, db_user, db_search_space, *, mention_id=1, preview="hi"):
|
||||
async def _notify(db_session, db_user, db_workspace, *, mention_id=1, preview="hi"):
|
||||
"""Raise an @mention notification for the assertions in the tests below."""
|
||||
return await handler.notify_new_mention(
|
||||
session=db_session,
|
||||
|
|
@ -28,15 +28,15 @@ async def _notify(db_session, db_user, db_search_space, *, mention_id=1, preview
|
|||
author_avatar_url=None,
|
||||
author_email="alice@surfsense.net",
|
||||
content_preview=preview,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
)
|
||||
|
||||
|
||||
async def test_new_mention_title_and_message(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A mention notification names the author and carries the comment preview."""
|
||||
notification = await _notify(db_session, db_user, db_search_space, preview="hello")
|
||||
notification = await _notify(db_session, db_user, db_workspace, preview="hello")
|
||||
|
||||
assert notification.type == "new_mention"
|
||||
assert notification.title == "Alice mentioned you"
|
||||
|
|
@ -44,21 +44,19 @@ async def test_new_mention_title_and_message(
|
|||
|
||||
|
||||
async def test_new_mention_truncates_long_preview(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""A long comment preview is truncated in the mention message."""
|
||||
notification = await _notify(
|
||||
db_session, db_user, db_search_space, preview="x" * 150
|
||||
)
|
||||
notification = await _notify(db_session, db_user, db_workspace, preview="x" * 150)
|
||||
|
||||
assert notification.message == "x" * 100 + "..."
|
||||
|
||||
|
||||
async def test_new_mention_is_idempotent(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Re-notifying the same mention id reuses the existing notification row."""
|
||||
first = await _notify(db_session, db_user, db_search_space, mention_id=7)
|
||||
second = await _notify(db_session, db_user, db_search_space, mention_id=7)
|
||||
first = await _notify(db_session, db_user, db_workspace, mention_id=7)
|
||||
second = await _notify(db_session, db_user, db_workspace, mention_id=7)
|
||||
|
||||
assert second.id == first.id
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.app import app, limiter
|
||||
from app.auth.context import AuthContext
|
||||
from app.config import config as app_config
|
||||
from app.db import SearchSpace, User, get_async_session
|
||||
from app.db import User, Workspace, get_async_session
|
||||
from app.podcasts.persistence import Podcast, PodcastStatus
|
||||
from app.podcasts.schemas import (
|
||||
DurationTarget,
|
||||
|
|
@ -39,7 +39,7 @@ from app.podcasts.schemas import (
|
|||
)
|
||||
from app.podcasts.service import PodcastService
|
||||
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
|
||||
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
||||
from app.routes.workspaces_routes import create_default_roles_and_membership
|
||||
from app.users import get_auth_context
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
|
@ -248,14 +248,14 @@ def make_podcast(db_session: AsyncSession):
|
|||
|
||||
async def _make(
|
||||
*,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
status: PodcastStatus = PodcastStatus.AWAITING_BRIEF,
|
||||
title: str = "Test Podcast",
|
||||
thread_id: int | None = None,
|
||||
) -> Podcast:
|
||||
service = PodcastService(db_session)
|
||||
podcast = await service.create(
|
||||
title=title, search_space_id=search_space_id, thread_id=thread_id
|
||||
title=title, workspace_id=workspace_id, thread_id=thread_id
|
||||
)
|
||||
if status is PodcastStatus.PENDING:
|
||||
await db_session.flush()
|
||||
|
|
@ -298,7 +298,7 @@ def act_as():
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_other_user(db_session: AsyncSession) -> User:
|
||||
"""A second user who is not a member of ``db_search_space``."""
|
||||
"""A second user who is not a member of ``db_workspace``."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="stranger@surfsense.net",
|
||||
|
|
@ -317,9 +317,9 @@ async def foreign_podcast(
|
|||
db_session: AsyncSession, db_other_user: User, make_podcast
|
||||
) -> Podcast:
|
||||
"""A podcast in a space owned by the other user, invisible to db_user."""
|
||||
space = SearchSpace(name="Stranger Space", user_id=db_other_user.id)
|
||||
space = Workspace(name="Stranger Space", user_id=db_other_user.id)
|
||||
db_session.add(space)
|
||||
await db_session.flush()
|
||||
await create_default_roles_and_membership(db_session, space.id, db_other_user.id)
|
||||
await db_session.flush()
|
||||
return await make_podcast(search_space_id=space.id, title="Foreign")
|
||||
return await make_podcast(workspace_id=space.id, title="Foreign")
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
|
|||
BASE = "/api/v1/podcasts"
|
||||
|
||||
|
||||
async def _create(client, search_space_id: int) -> dict:
|
||||
async def _create(client, workspace_id: int) -> dict:
|
||||
resp = await client.post(
|
||||
BASE,
|
||||
json={
|
||||
"title": "Episode",
|
||||
"search_space_id": search_space_id,
|
||||
"workspace_id": workspace_id,
|
||||
"source_content": "Source content.",
|
||||
},
|
||||
)
|
||||
|
|
@ -28,20 +28,20 @@ async def _create(client, search_space_id: int) -> dict:
|
|||
|
||||
|
||||
async def test_approve_brief_starts_drafting_and_enqueues_draft(
|
||||
client, db_search_space, captured_tasks
|
||||
client, db_workspace, captured_tasks
|
||||
):
|
||||
podcast = await _create(client, db_search_space.id)
|
||||
podcast = await _create(client, db_workspace.id)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast['id']}/brief/approve")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "drafting"
|
||||
assert captured_tasks.draft == [((podcast["id"], db_search_space.id), {})]
|
||||
assert captured_tasks.draft == [((podcast["id"], db_workspace.id), {})]
|
||||
assert captured_tasks.render == []
|
||||
|
||||
|
||||
async def test_update_spec_bumps_version_and_persists(client, db_search_space):
|
||||
podcast = await _create(client, db_search_space.id)
|
||||
async def test_update_spec_bumps_version_and_persists(client, db_workspace):
|
||||
podcast = await _create(client, db_workspace.id)
|
||||
spec = podcast["spec"]
|
||||
spec["focus"] = "A sharper angle"
|
||||
|
||||
|
|
@ -57,8 +57,8 @@ async def test_update_spec_bumps_version_and_persists(client, db_search_space):
|
|||
assert body["status"] == "awaiting_brief"
|
||||
|
||||
|
||||
async def test_update_spec_with_stale_version_conflicts(client, db_search_space):
|
||||
podcast = await _create(client, db_search_space.id)
|
||||
async def test_update_spec_with_stale_version_conflicts(client, db_workspace):
|
||||
podcast = await _create(client, db_workspace.id)
|
||||
|
||||
resp = await client.patch(
|
||||
f"{BASE}/{podcast['id']}/spec",
|
||||
|
|
@ -68,8 +68,8 @@ async def test_update_spec_with_stale_version_conflicts(client, db_search_space)
|
|||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_update_spec_after_approval_is_rejected(client, db_search_space):
|
||||
podcast = await _create(client, db_search_space.id)
|
||||
async def test_update_spec_after_approval_is_rejected(client, db_workspace):
|
||||
podcast = await _create(client, db_workspace.id)
|
||||
await client.post(f"{BASE}/{podcast['id']}/brief/approve")
|
||||
|
||||
resp = await client.patch(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ pytestmark = pytest.mark.integration
|
|||
BASE = "/api/v1/podcasts"
|
||||
|
||||
|
||||
async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
|
||||
async def test_cancel_from_a_live_state_succeeds(client, db_workspace, make_podcast):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
|
||||
|
|
@ -27,10 +27,10 @@ async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_p
|
|||
|
||||
|
||||
async def test_cancel_from_a_terminal_state_conflicts(
|
||||
client, db_search_space, make_podcast
|
||||
client, db_workspace, make_podcast
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
|
||||
|
|
@ -38,13 +38,11 @@ async def test_cancel_from_a_terminal_state_conflicts(
|
|||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_cancel_of_a_regeneration_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
async def test_cancel_of_a_regeneration_is_rejected(client, db_workspace, make_podcast):
|
||||
# Cancelling here would destroy a playable episode; reverting the
|
||||
# regeneration is the way back.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
|
|||
BASE = "/api/v1/podcasts"
|
||||
|
||||
|
||||
async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
|
||||
async def test_create_proposes_brief_and_opens_gate(client, db_workspace):
|
||||
resp = await client.post(
|
||||
BASE,
|
||||
json={
|
||||
"title": "My Episode",
|
||||
"search_space_id": db_search_space.id,
|
||||
"workspace_id": db_workspace.id,
|
||||
"source_content": "A long piece of source content about a topic.",
|
||||
},
|
||||
)
|
||||
|
|
@ -36,12 +36,12 @@ async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
|
|||
assert body["has_audio"] is False
|
||||
|
||||
|
||||
async def test_create_honors_requested_speaker_count(client, db_search_space):
|
||||
async def test_create_honors_requested_speaker_count(client, db_workspace):
|
||||
resp = await client.post(
|
||||
BASE,
|
||||
json={
|
||||
"title": "Solo",
|
||||
"search_space_id": db_search_space.id,
|
||||
"workspace_id": db_workspace.id,
|
||||
"source_content": "Content.",
|
||||
"speaker_count": 3,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,22 +33,22 @@ pytestmark = pytest.mark.integration
|
|||
def _wire_billing(monkeypatch, *, billable_call, transcript=None) -> None:
|
||||
"""Replace the billing + LLM externals the draft body reaches for."""
|
||||
|
||||
async def _resolver(_session, _search_space_id, *, thread_id=None):
|
||||
async def _resolver(_session, _workspace_id, *, thread_id=None):
|
||||
return uuid4(), "free", "openrouter/model"
|
||||
|
||||
async def _ainvoke(_state, config=None):
|
||||
return {"transcript": transcript}
|
||||
|
||||
monkeypatch.setattr(draft, "_resolve_agent_billing_for_search_space", _resolver)
|
||||
monkeypatch.setattr(draft, "_resolve_agent_billing_for_workspace", _resolver)
|
||||
monkeypatch.setattr(draft, "billable_call", billable_call)
|
||||
monkeypatch.setattr(draft, "transcript_graph", SimpleNamespace(ainvoke=_ainvoke))
|
||||
|
||||
|
||||
async def test_successful_draft_stores_transcript_and_starts_rendering(
|
||||
monkeypatch, db_search_space, make_podcast, bind_task_session, captured_tasks
|
||||
monkeypatch, db_workspace, make_podcast, bind_task_session, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -57,7 +57,7 @@ async def test_successful_draft_stores_transcript_and_starts_rendering(
|
|||
|
||||
_wire_billing(monkeypatch, billable_call=_ok, transcript=build_transcript())
|
||||
|
||||
result = await draft._draft_transcript(podcast.id, db_search_space.id)
|
||||
result = await draft._draft_transcript(podcast.id, db_workspace.id)
|
||||
|
||||
assert result["status"] == "rendering"
|
||||
assert podcast.status == PodcastStatus.RENDERING
|
||||
|
|
@ -66,10 +66,10 @@ async def test_successful_draft_stores_transcript_and_starts_rendering(
|
|||
|
||||
|
||||
async def test_quota_denial_fails_the_podcast_without_a_transcript(
|
||||
monkeypatch, db_search_space, make_podcast, bind_task_session
|
||||
monkeypatch, db_workspace, make_podcast, bind_task_session
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -83,7 +83,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
|
|||
|
||||
_wire_billing(monkeypatch, billable_call=_deny)
|
||||
|
||||
result = await draft._draft_transcript(podcast.id, db_search_space.id)
|
||||
result = await draft._draft_transcript(podcast.id, db_workspace.id)
|
||||
|
||||
assert result["reason"] == "quota"
|
||||
assert podcast.status == PodcastStatus.FAILED
|
||||
|
|
@ -91,10 +91,10 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
|
|||
|
||||
|
||||
async def test_billing_settlement_failure_fails_the_podcast(
|
||||
monkeypatch, db_search_space, make_podcast, bind_task_session
|
||||
monkeypatch, db_workspace, make_podcast, bind_task_session
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -110,7 +110,7 @@ async def test_billing_settlement_failure_fails_the_podcast(
|
|||
monkeypatch, billable_call=_settlement_fails, transcript=build_transcript()
|
||||
)
|
||||
|
||||
result = await draft._draft_transcript(podcast.id, db_search_space.id)
|
||||
result = await draft._draft_transcript(podcast.id, db_workspace.id)
|
||||
|
||||
assert result["reason"] == "billing"
|
||||
assert podcast.status == PodcastStatus.FAILED
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ from app.db import NewChatThread, PublicChatSnapshot, User
|
|||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _snapshot(db_session, *, search_space_id, user: User, token: str, podcasts):
|
||||
async def _snapshot(db_session, *, workspace_id, user: User, token: str, podcasts):
|
||||
thread = NewChatThread(
|
||||
title="Shared", search_space_id=search_space_id, created_by_id=user.id
|
||||
title="Shared", workspace_id=workspace_id, created_by_id=user.id
|
||||
)
|
||||
db_session.add(thread)
|
||||
await db_session.flush()
|
||||
|
|
@ -30,11 +30,11 @@ async def _snapshot(db_session, *, search_space_id, user: User, token: str, podc
|
|||
|
||||
|
||||
async def test_public_stream_serves_audio_via_storage_key(
|
||||
client, db_session, db_search_space, db_user, fake_storage
|
||||
client, db_session, db_workspace, db_user, fake_storage
|
||||
):
|
||||
await _snapshot(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user=db_user,
|
||||
token="tok-audio",
|
||||
podcasts=[{"original_id": 555, "storage_key": "podcasts/x.mp3"}],
|
||||
|
|
@ -49,11 +49,11 @@ async def test_public_stream_serves_audio_via_storage_key(
|
|||
|
||||
|
||||
async def test_public_stream_404_when_object_missing(
|
||||
client, db_session, db_search_space, db_user, fake_storage
|
||||
client, db_session, db_workspace, db_user, fake_storage
|
||||
):
|
||||
await _snapshot(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user=db_user,
|
||||
token="tok-gone",
|
||||
podcasts=[{"original_id": 556, "storage_key": "podcasts/gone.mp3"}],
|
||||
|
|
@ -65,11 +65,11 @@ async def test_public_stream_404_when_object_missing(
|
|||
|
||||
|
||||
async def test_public_stream_404_when_podcast_absent_from_snapshot(
|
||||
client, db_session, db_search_space, db_user
|
||||
client, db_session, db_workspace, db_user
|
||||
):
|
||||
await _snapshot(
|
||||
db_session,
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
user=db_user,
|
||||
token="tok-empty",
|
||||
podcasts=[],
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ BASE = "/api/v1/podcasts"
|
|||
|
||||
|
||||
async def test_regenerate_from_ready_reopens_the_brief_gate(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
client, db_workspace, make_podcast, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
|
@ -43,10 +43,10 @@ async def test_regenerate_from_ready_reopens_the_brief_gate(
|
|||
|
||||
|
||||
async def test_approving_the_reopened_brief_starts_a_fresh_draft(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
client, db_workspace, make_podcast, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
|
|
@ -54,15 +54,15 @@ async def test_approving_the_reopened_brief_starts_a_fresh_draft(
|
|||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "drafting"
|
||||
assert captured_tasks.draft == [((podcast.id, db_search_space.id), {})]
|
||||
assert captured_tasks.draft == [((podcast.id, db_workspace.id), {})]
|
||||
|
||||
|
||||
async def test_regenerate_from_brief_gate_is_rejected(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
client, db_workspace, make_podcast, captured_tasks
|
||||
):
|
||||
# Nothing has been drafted yet, so there is nothing to regenerate.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
|
@ -72,10 +72,10 @@ async def test_regenerate_from_brief_gate_is_rejected(
|
|||
|
||||
|
||||
async def test_regenerate_from_cancelled_is_rejected(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
client, db_workspace, make_podcast, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/cancel")
|
||||
|
||||
|
|
@ -86,10 +86,10 @@ async def test_regenerate_from_cancelled_is_rejected(
|
|||
|
||||
|
||||
async def test_reverting_a_regeneration_restores_the_ready_episode(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
client, db_workspace, make_podcast, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
|
|
@ -105,12 +105,12 @@ async def test_reverting_a_regeneration_restores_the_ready_episode(
|
|||
|
||||
|
||||
async def test_reverting_mid_draft_keeps_the_episode(
|
||||
client, db_search_space, make_podcast
|
||||
client, db_workspace, make_podcast
|
||||
):
|
||||
# Changing one's mind is allowed even after the reopened brief was
|
||||
# approved: the episode survives until a new render replaces it.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
await client.post(f"{BASE}/{podcast.id}/brief/approve")
|
||||
|
|
@ -122,10 +122,10 @@ async def test_reverting_mid_draft_keeps_the_episode(
|
|||
|
||||
|
||||
async def test_reverting_mid_render_keeps_the_episode(
|
||||
client, db_session, db_search_space, make_podcast
|
||||
client, db_session, db_workspace, make_podcast
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
service = PodcastService(db_session)
|
||||
await service.regenerate(podcast)
|
||||
|
|
@ -139,12 +139,12 @@ async def test_reverting_mid_render_keeps_the_episode(
|
|||
|
||||
|
||||
async def test_reverted_episode_can_be_regenerated_again(
|
||||
client, db_search_space, make_podcast
|
||||
client, db_workspace, make_podcast
|
||||
):
|
||||
# Reverting must not strand the episode: the user can change their mind
|
||||
# again immediately.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
|
@ -156,11 +156,11 @@ async def test_reverted_episode_can_be_regenerated_again(
|
|||
|
||||
|
||||
async def test_revert_on_a_fresh_brief_gate_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
client, db_workspace, make_podcast
|
||||
):
|
||||
# A first-time brief has no regeneration to revert.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
|
@ -170,10 +170,10 @@ async def test_revert_on_a_fresh_brief_gate_is_rejected(
|
|||
|
||||
|
||||
async def test_revert_when_nothing_was_regenerated_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
client, db_workspace, make_podcast
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
|
@ -182,13 +182,13 @@ async def test_revert_when_nothing_was_regenerated_is_rejected(
|
|||
|
||||
|
||||
async def test_regenerate_without_a_brief_is_rejected(
|
||||
client, db_session, db_search_space, captured_tasks
|
||||
client, db_session, db_workspace, captured_tasks
|
||||
):
|
||||
# Legacy episodes finished before briefs existed; reopening a gate with
|
||||
# nothing to review would strand them there.
|
||||
podcast = Podcast(
|
||||
title="Legacy Episode",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
status=PodcastStatus.READY,
|
||||
spec_version=1,
|
||||
file_location="/var/old/podcast.mp3",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
async def test_render_marks_ready_and_stores_audio(
|
||||
db_search_space, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
|
||||
db_workspace, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.RENDERING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.RENDERING
|
||||
)
|
||||
|
||||
result = await render._render_audio(podcast.id)
|
||||
|
|
@ -37,7 +37,7 @@ async def test_render_marks_ready_and_stores_audio(
|
|||
|
||||
async def test_rerender_replaces_audio_and_purges_the_old_object(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_podcast,
|
||||
bind_task_session,
|
||||
fake_tts,
|
||||
|
|
@ -47,7 +47,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
|
|||
# A regenerated episode keeps exactly one stored object: the new render
|
||||
# must not leak the superseded audio in the object store.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
old_key = podcast.storage_key
|
||||
fake_storage.objects[old_key] = b"old-audio"
|
||||
|
|
@ -68,7 +68,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
|
|||
|
||||
async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
|
||||
db_session,
|
||||
db_search_space,
|
||||
db_workspace,
|
||||
make_podcast,
|
||||
bind_task_session,
|
||||
fake_tts,
|
||||
|
|
@ -79,7 +79,7 @@ async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothin
|
|||
# stale render must neither resurrect the redo nor leak the object it
|
||||
# already stored.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
old_key = podcast.storage_key
|
||||
fake_storage.objects[old_key] = b"old-audio"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Podcasts are scoped to search-space membership.
|
||||
"""Podcasts are scoped to workspace membership.
|
||||
|
||||
A user can only create or read podcasts in spaces they belong to, and an
|
||||
unscoped listing returns only the caller's own podcasts — never another
|
||||
|
|
@ -13,9 +13,9 @@ BASE = "/api/v1/podcasts"
|
|||
|
||||
|
||||
async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
|
||||
client, db_search_space, make_podcast, act_as, db_other_user
|
||||
client, db_workspace, make_podcast, act_as, db_other_user
|
||||
):
|
||||
podcast = await make_podcast(search_space_id=db_search_space.id)
|
||||
podcast = await make_podcast(workspace_id=db_workspace.id)
|
||||
act_as(db_other_user)
|
||||
|
||||
resp = await client.get(f"{BASE}/{podcast.id}")
|
||||
|
|
@ -24,7 +24,7 @@ async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
|
|||
|
||||
|
||||
async def test_creating_in_a_nonmember_space_is_forbidden(
|
||||
client, db_search_space, act_as, db_other_user
|
||||
client, db_workspace, act_as, db_other_user
|
||||
):
|
||||
act_as(db_other_user)
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
|
|||
BASE,
|
||||
json={
|
||||
"title": "X",
|
||||
"search_space_id": db_search_space.id,
|
||||
"workspace_id": db_workspace.id,
|
||||
"source_content": "content",
|
||||
},
|
||||
)
|
||||
|
|
@ -41,9 +41,9 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
|
|||
|
||||
|
||||
async def test_listing_returns_only_the_callers_podcasts(
|
||||
client, db_search_space, make_podcast, foreign_podcast
|
||||
client, db_workspace, make_podcast, foreign_podcast
|
||||
):
|
||||
mine = await make_podcast(search_space_id=db_search_space.id, title="Mine")
|
||||
mine = await make_podcast(workspace_id=db_workspace.id, title="Mine")
|
||||
|
||||
resp = await client.get(BASE)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ BASE = "/api/v1/podcasts"
|
|||
|
||||
|
||||
async def test_stream_serves_stored_audio(
|
||||
client, db_search_space, make_podcast, fake_storage
|
||||
client, db_workspace, make_podcast, fake_storage
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
fake_storage.objects["podcasts/audio.mp3"] = b"the-audio"
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ async def test_stream_serves_stored_audio(
|
|||
assert resp.content == b"the-audio"
|
||||
|
||||
|
||||
async def test_stream_409_while_in_flight(client, db_search_space, make_podcast):
|
||||
async def test_stream_409_while_in_flight(client, db_workspace, make_podcast):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
|
||||
)
|
||||
|
||||
resp = await client.get(f"{BASE}/{podcast.id}/stream")
|
||||
|
|
@ -41,10 +41,10 @@ async def test_stream_409_while_in_flight(client, db_search_space, make_podcast)
|
|||
|
||||
|
||||
async def test_stream_404_when_object_missing(
|
||||
client, db_search_space, make_podcast, fake_storage
|
||||
client, db_workspace, make_podcast, fake_storage
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
resp = await client.get(f"{BASE}/{podcast.id}/stream")
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
|
||||
async def test_marking_failed_records_the_reason_on_a_running_podcast(
|
||||
db_search_space, make_podcast, bind_task_session
|
||||
db_workspace, make_podcast, bind_task_session
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
|
||||
)
|
||||
|
||||
await runtime.mark_failed(podcast.id, "tts provider unavailable")
|
||||
|
|
@ -30,10 +30,10 @@ async def test_marking_failed_records_the_reason_on_a_running_podcast(
|
|||
|
||||
|
||||
async def test_marking_failed_leaves_an_already_terminal_podcast_untouched(
|
||||
db_search_space, make_podcast, bind_task_session
|
||||
db_workspace, make_podcast, bind_task_session
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
workspace_id=db_workspace.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
await runtime.mark_failed(podcast.id, "too late")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import pytest_asyncio
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config as app_config
|
||||
from app.db import Chunk, Document, DocumentType, SearchSpace, User
|
||||
from app.db import Chunk, Document, DocumentType, User, Workspace
|
||||
|
||||
EMBEDDING_DIM = app_config.embedding_model_instance.dimension
|
||||
DUMMY_EMBEDDING = [0.1] * EMBEDDING_DIM
|
||||
|
|
@ -20,7 +20,7 @@ def _make_document(
|
|||
title: str,
|
||||
document_type: DocumentType,
|
||||
content: str,
|
||||
search_space_id: int,
|
||||
workspace_id: int,
|
||||
created_by_id: str,
|
||||
updated_at: datetime | None = None,
|
||||
) -> Document:
|
||||
|
|
@ -32,7 +32,7 @@ def _make_document(
|
|||
content_hash=f"content-{uid}",
|
||||
unique_identifier_hash=f"uid-{uid}",
|
||||
source_markdown=content,
|
||||
search_space_id=search_space_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
embedding=DUMMY_EMBEDDING,
|
||||
updated_at=updated_at or datetime.now(UTC),
|
||||
|
|
@ -50,29 +50,29 @@ def _make_chunk(*, content: str, document_id: int) -> Chunk:
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_large_doc(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Insert a document with 35 chunks (more than _MAX_FETCH_CHUNKS_PER_DOC=20).
|
||||
|
||||
Also inserts a small 3-chunk document for diversity testing.
|
||||
Returns a dict with ``large_doc``, ``small_doc``, ``search_space``, ``user``,
|
||||
Returns a dict with ``large_doc``, ``small_doc``, ``workspace``, ``user``,
|
||||
and ``large_chunk_ids`` (all 35 chunk IDs).
|
||||
"""
|
||||
user_id = str(db_user.id)
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
|
||||
large_doc = _make_document(
|
||||
title="Large PDF Document",
|
||||
document_type=DocumentType.FILE,
|
||||
content="large document about quarterly performance reviews and budgets",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
small_doc = _make_document(
|
||||
title="Small Note",
|
||||
document_type=DocumentType.NOTE,
|
||||
content="quarterly performance review summary note",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
|
|
@ -102,25 +102,25 @@ async def seed_large_doc(
|
|||
"small_doc": small_doc,
|
||||
"large_chunk_ids": [c.id for c in large_chunks],
|
||||
"small_chunk_ids": [c.id for c in small_chunks],
|
||||
"search_space": db_search_space,
|
||||
"workspace": db_workspace,
|
||||
"user": db_user,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_date_filtered_docs(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
"""Insert matching docs with different timestamps for date-filter tests."""
|
||||
user_id = str(db_user.id)
|
||||
space_id = db_search_space.id
|
||||
space_id = db_workspace.id
|
||||
now = datetime.now(UTC)
|
||||
|
||||
recent_doc = _make_document(
|
||||
title="Recent OCV Notes",
|
||||
document_type=DocumentType.FILE,
|
||||
content="ocv meeting decisions and action items",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
updated_at=now,
|
||||
)
|
||||
|
|
@ -128,7 +128,7 @@ async def seed_date_filtered_docs(
|
|||
title="Old OCV Notes",
|
||||
document_type=DocumentType.FILE,
|
||||
content="ocv meeting decisions and action items",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
created_by_id=user_id,
|
||||
updated_at=now - timedelta(days=730),
|
||||
)
|
||||
|
|
@ -153,6 +153,6 @@ async def seed_date_filtered_docs(
|
|||
return {
|
||||
"recent_doc": recent_doc,
|
||||
"old_doc": old_doc,
|
||||
"search_space": db_search_space,
|
||||
"workspace": db_workspace,
|
||||
"user": db_user,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
|
||||
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -40,13 +40,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
|
|||
|
||||
async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
|
||||
"""Document metadata (title, type, etc.) should be present even without joinedload."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -62,13 +62,13 @@ async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
|
|||
|
||||
async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
|
||||
"""matched_chunk_ids should contain the chunk IDs that appeared in the RRF results."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -83,13 +83,13 @@ async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
|
|||
|
||||
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
|
||||
"""Chunks within each document should be ordered by chunk ID (original order)."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -100,13 +100,13 @@ async def test_chunks_ordered_by_id(db_session, seed_large_doc):
|
|||
|
||||
async def test_score_is_positive_float(db_session, seed_large_doc):
|
||||
"""Each result should have a positive float score from RRF."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = ChucksHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
|
||||
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = DocumentHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -39,13 +39,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
|
|||
|
||||
async def test_doc_metadata_populated(db_session, seed_large_doc):
|
||||
"""Document metadata should be present from the RRF results."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = DocumentHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
@ -61,13 +61,13 @@ async def test_doc_metadata_populated(db_session, seed_large_doc):
|
|||
|
||||
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
|
||||
"""Chunks within each document should be ordered by chunk ID."""
|
||||
space_id = seed_large_doc["search_space"].id
|
||||
space_id = seed_large_doc["workspace"].id
|
||||
|
||||
retriever = DocumentHybridSearchRetriever(db_session)
|
||||
results = await retriever.hybrid_search(
|
||||
query_text="quarterly performance review",
|
||||
top_k=10,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
query_embedding=DUMMY_EMBEDDING,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Cross-search-space authorization on the connector index endpoint.
|
||||
"""Cross-workspace authorization on the connector index endpoint.
|
||||
|
||||
``POST /search-source-connectors/{connector_id}/index?search_space_id=<X>`` must
|
||||
authorize against the **connector's own** ``search_space_id`` (matching the
|
||||
read/update/delete handlers), not the caller-supplied ``search_space_id`` query
|
||||
``POST /search-source-connectors/{connector_id}/index?workspace_id=<X>`` must
|
||||
authorize against the **connector's own** ``workspace_id`` (matching the
|
||||
read/update/delete handlers), not the caller-supplied ``workspace_id`` query
|
||||
parameter, and must reject a connector that does not belong to the requested
|
||||
search space.
|
||||
workspace.
|
||||
|
||||
Without this, a user who owns search space B could index another user's
|
||||
connector (which lives in space A) by passing ``search_space_id=B``: the
|
||||
Without this, a user who owns workspace B could index another user's
|
||||
connector (which lives in space A) by passing ``workspace_id=B``: the
|
||||
background indexer would run with the **victim connector's stored credentials**
|
||||
and write the fetched content into the attacker's space. These tests pin that
|
||||
boundary.
|
||||
|
|
@ -27,11 +27,11 @@ from app.auth.context import AuthContext
|
|||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.routes.search_source_connectors_routes import index_connector_content
|
||||
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
||||
from app.routes.workspaces_routes import create_default_roles_and_membership
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -39,9 +39,9 @@ pytestmark = pytest.mark.integration
|
|||
_CHECK_PERMISSION = "app.routes.search_source_connectors_routes.check_permission"
|
||||
|
||||
|
||||
async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpace]:
|
||||
"""A user plus a search space they own, with the default roles/membership
|
||||
the ``POST /searchspaces`` route would create (so ``check_permission`` would
|
||||
async def _make_user_with_space(session: AsyncSession) -> tuple[User, Workspace]:
|
||||
"""A user plus a workspace they own, with the default roles/membership
|
||||
the ``POST /workspaces`` route would create (so ``check_permission`` would
|
||||
legitimately pass for this user on this space)."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
|
|
@ -53,7 +53,7 @@ async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpac
|
|||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
space = SearchSpace(name=f"Space {uuid.uuid4().hex[:8]}", user_id=user.id)
|
||||
space = Workspace(name=f"Space {uuid.uuid4().hex[:8]}", user_id=user.id)
|
||||
session.add(space)
|
||||
await session.flush()
|
||||
await create_default_roles_and_membership(session, space.id, user.id)
|
||||
|
|
@ -64,7 +64,7 @@ async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpac
|
|||
async def _make_connector(
|
||||
session: AsyncSession,
|
||||
owner: User,
|
||||
space: SearchSpace,
|
||||
space: Workspace,
|
||||
connector_type: SearchSourceConnectorType,
|
||||
) -> SearchSourceConnector:
|
||||
connector = SearchSourceConnector(
|
||||
|
|
@ -77,7 +77,7 @@ async def _make_connector(
|
|||
"repo_full_names": ["octocat/Hello-World"],
|
||||
},
|
||||
is_indexable=True,
|
||||
search_space_id=space.id,
|
||||
workspace_id=space.id,
|
||||
user_id=owner.id,
|
||||
)
|
||||
session.add(connector)
|
||||
|
|
@ -90,7 +90,7 @@ class TestConnectorIndexCrossSpaceAuthz:
|
|||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Attacker (owns space B) cannot index victim's connector (in space A)
|
||||
by passing ``search_space_id=B``.
|
||||
by passing ``workspace_id=B``.
|
||||
|
||||
The mismatch is rejected with 404 **before** ``check_permission`` runs —
|
||||
which is essential, because that permission check *would* pass: the
|
||||
|
|
@ -108,13 +108,13 @@ class TestConnectorIndexCrossSpaceAuthz:
|
|||
):
|
||||
await index_connector_content(
|
||||
connector_id=connector_a.id,
|
||||
search_space_id=space_b.id, # the attacker's own space
|
||||
workspace_id=space_b.id, # the attacker's own space
|
||||
session=db_session,
|
||||
auth=AuthContext.session(attacker),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
# Rejected at the search-space reconciliation, never reaching (or relying
|
||||
# Rejected at the workspace reconciliation, never reaching (or relying
|
||||
# on) the permission check — which would have passed for space B.
|
||||
check_permission_mock.assert_not_awaited()
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ class TestConnectorIndexCrossSpaceAuthz:
|
|||
self, db_session: AsyncSession
|
||||
):
|
||||
"""A legitimate same-space index passes the reconciliation and authorizes
|
||||
``check_permission`` against the connector's **own** search space (not the
|
||||
``check_permission`` against the connector's **own** workspace (not the
|
||||
client-supplied query param)."""
|
||||
owner, space = await _make_user_with_space(db_session)
|
||||
# A "live" connector type returns early (no Celery dispatch) right after
|
||||
|
|
@ -139,11 +139,11 @@ class TestConnectorIndexCrossSpaceAuthz:
|
|||
):
|
||||
await index_connector_content(
|
||||
connector_id=connector.id,
|
||||
search_space_id=space.id, # the connector's own space
|
||||
workspace_id=space.id, # the connector's own space
|
||||
session=db_session,
|
||||
auth=AuthContext.session(owner),
|
||||
)
|
||||
|
||||
check_permission_mock.assert_awaited_once()
|
||||
# The space passed to check_permission must be the connector's own space.
|
||||
assert connector.search_space_id in check_permission_mock.await_args.args
|
||||
assert connector.workspace_id in check_permission_mock.await_args.args
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import pytest_asyncio
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import Document, DocumentType, DocumentVersion, SearchSpace, User
|
||||
from app.db import Document, DocumentType, DocumentVersion, User, Workspace
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_document(
|
||||
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
) -> Document:
|
||||
doc = Document(
|
||||
title="Test Doc",
|
||||
|
|
@ -24,7 +24,7 @@ async def db_document(
|
|||
content_hash="abc123",
|
||||
unique_identifier_hash="local_folder:test-folder:test.md",
|
||||
source_markdown="# Test\n\nOriginal content.",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
created_by_id=db_user.id,
|
||||
)
|
||||
db_session.add(doc)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ from app.auth.context import AuthContext
|
|||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from app.routes.obsidian_plugin_routes import (
|
||||
obsidian_connect,
|
||||
|
|
@ -43,7 +43,7 @@ from app.routes.obsidian_plugin_routes import (
|
|||
obsidian_stats,
|
||||
obsidian_sync,
|
||||
)
|
||||
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
||||
from app.routes.workspaces_routes import create_default_roles_and_membership
|
||||
from app.schemas.obsidian_plugin import (
|
||||
ConnectRequest,
|
||||
DeleteAck,
|
||||
|
|
@ -90,7 +90,7 @@ def _make_note_payload(vault_id: str, path: str, content_hash: str) -> NotePaylo
|
|||
|
||||
@pytest_asyncio.fixture
|
||||
async def race_user_and_space(async_engine):
|
||||
"""User + SearchSpace committed via the live engine so the two
|
||||
"""User + Workspace committed via the live engine so the two
|
||||
concurrent /connect sessions in the race test can both see them.
|
||||
|
||||
We can't use the savepoint-trapped ``db_session`` fixture here
|
||||
|
|
@ -106,7 +106,7 @@ async def race_user_and_space(async_engine):
|
|||
is_superuser=False,
|
||||
is_verified=True,
|
||||
)
|
||||
space = SearchSpace(name="Race Space", user_id=user_id)
|
||||
space = Workspace(name="Race Space", user_id=user_id)
|
||||
setup.add_all([user, space])
|
||||
await setup.flush()
|
||||
await create_default_roles_and_membership(setup, space.id, user_id)
|
||||
|
|
@ -125,15 +125,15 @@ async def race_user_and_space(async_engine):
|
|||
{"uid": user_id},
|
||||
)
|
||||
await cleanup.execute(
|
||||
text("DELETE FROM search_space_memberships WHERE search_space_id = :id"),
|
||||
text("DELETE FROM workspace_memberships WHERE workspace_id = :id"),
|
||||
{"id": space_id},
|
||||
)
|
||||
await cleanup.execute(
|
||||
text("DELETE FROM search_space_roles WHERE search_space_id = :id"),
|
||||
text("DELETE FROM workspace_roles WHERE workspace_id = :id"),
|
||||
{"id": space_id},
|
||||
)
|
||||
await cleanup.execute(
|
||||
text("DELETE FROM searchspaces WHERE id = :id"),
|
||||
text("DELETE FROM workspaces WHERE id = :id"),
|
||||
{"id": space_id},
|
||||
)
|
||||
await cleanup.execute(
|
||||
|
|
@ -167,7 +167,7 @@ class TestConnectRace:
|
|||
payload = ConnectRequest(
|
||||
vault_id=vault_id,
|
||||
vault_name=f"My Vault {name_suffix}",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
vault_fingerprint=fingerprint,
|
||||
)
|
||||
await obsidian_connect(payload, auth=_auth(fresh_user), session=s)
|
||||
|
|
@ -207,7 +207,7 @@ class TestConnectRace:
|
|||
"vault_fingerprint": "fp-1",
|
||||
},
|
||||
user_id=user_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
|
@ -226,7 +226,7 @@ class TestConnectRace:
|
|||
"vault_fingerprint": "fp-2",
|
||||
},
|
||||
user_id=user_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
|
@ -252,7 +252,7 @@ class TestConnectRace:
|
|||
"vault_fingerprint": fingerprint,
|
||||
},
|
||||
user_id=user_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
|
@ -271,7 +271,7 @@ class TestConnectRace:
|
|||
"vault_fingerprint": fingerprint,
|
||||
},
|
||||
user_id=user_id,
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
|
@ -294,7 +294,7 @@ class TestConnectRace:
|
|||
ConnectRequest(
|
||||
vault_id=vault_id_a,
|
||||
vault_name="Shared Vault",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
vault_fingerprint=fingerprint,
|
||||
),
|
||||
auth=_auth(fresh_user),
|
||||
|
|
@ -307,7 +307,7 @@ class TestConnectRace:
|
|||
ConnectRequest(
|
||||
vault_id=vault_id_b,
|
||||
vault_name="Shared Vault",
|
||||
search_space_id=space_id,
|
||||
workspace_id=space_id,
|
||||
vault_fingerprint=fingerprint,
|
||||
),
|
||||
auth=_auth(fresh_user),
|
||||
|
|
@ -341,7 +341,7 @@ class TestWireContractSmoke:
|
|||
field renames the way the TypeScript decoder would catch them."""
|
||||
|
||||
async def test_full_flow_returns_typed_payloads(
|
||||
self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
vault_id = str(uuid.uuid4())
|
||||
|
||||
|
|
@ -350,7 +350,7 @@ class TestWireContractSmoke:
|
|||
ConnectRequest(
|
||||
vault_id=vault_id,
|
||||
vault_name="Smoke Vault",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
vault_fingerprint="fp-" + uuid.uuid4().hex,
|
||||
),
|
||||
auth=_auth(db_user),
|
||||
|
|
@ -488,14 +488,14 @@ class TestWireContractSmoke:
|
|||
assert stats_resp.last_sync_at is None
|
||||
|
||||
async def test_sync_queues_binary_attachments(
|
||||
self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
vault_id = str(uuid.uuid4())
|
||||
await obsidian_connect(
|
||||
ConnectRequest(
|
||||
vault_id=vault_id,
|
||||
vault_name="Queue Vault",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
vault_fingerprint="fp-" + uuid.uuid4().hex,
|
||||
),
|
||||
auth=_auth(db_user),
|
||||
|
|
@ -539,14 +539,14 @@ class TestWireContractSmoke:
|
|||
queue_mock.assert_called_once()
|
||||
|
||||
async def test_sync_rejects_unsupported_attachment_extension(
|
||||
self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
vault_id = str(uuid.uuid4())
|
||||
await obsidian_connect(
|
||||
ConnectRequest(
|
||||
vault_id=vault_id,
|
||||
vault_name="Reject Vault",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
vault_fingerprint="fp-" + uuid.uuid4().hex,
|
||||
),
|
||||
auth=_auth(db_user),
|
||||
|
|
@ -593,14 +593,14 @@ class TestWireContractSmoke:
|
|||
queue_mock.assert_not_called()
|
||||
|
||||
async def test_sync_rejects_mime_extension_mismatch(
|
||||
self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
||||
self, db_session: AsyncSession, db_user: User, db_workspace: Workspace
|
||||
):
|
||||
vault_id = str(uuid.uuid4())
|
||||
await obsidian_connect(
|
||||
ConnectRequest(
|
||||
vault_id=vault_id,
|
||||
vault_name="Mismatch Vault",
|
||||
search_space_id=db_search_space.id,
|
||||
workspace_id=db_workspace.id,
|
||||
vault_fingerprint="fp-" + uuid.uuid4().hex,
|
||||
),
|
||||
auth=_auth(db_user),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import PersonalAccessToken, SearchSpace, User
|
||||
from app.db import PersonalAccessToken, User, Workspace
|
||||
from app.users import allow_any_principal, require_session_context
|
||||
from app.utils.rbac import check_search_space_access
|
||||
from app.utils.rbac import check_workspace_access
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -43,29 +43,29 @@ async def test_pat_is_allowed_by_bootstrap_dependency(db_user: User):
|
|||
async def test_pat_is_rejected_for_api_disabled_space(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
db_search_space.api_access_enabled = False
|
||||
db_workspace.api_access_enabled = False
|
||||
await db_session.flush()
|
||||
auth = _pat_auth(db_user)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_search_space_access(db_session, auth, db_search_space.id)
|
||||
await check_workspace_access(db_session, auth, db_workspace.id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "API access is not enabled for this search space."
|
||||
assert exc_info.value.detail == "API access is not enabled for this workspace."
|
||||
|
||||
|
||||
async def test_pat_is_allowed_for_api_enabled_space(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
db_search_space.api_access_enabled = True
|
||||
db_workspace.api_access_enabled = True
|
||||
await db_session.flush()
|
||||
auth = _pat_auth(db_user)
|
||||
|
||||
membership = await check_search_space_access(db_session, auth, db_search_space.id)
|
||||
membership = await check_workspace_access(db_session, auth, db_workspace.id)
|
||||
|
||||
assert membership.user_id == db_user.id
|
||||
assert membership.search_space_id == db_search_space.id
|
||||
assert membership.workspace_id == db_workspace.id
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import PersonalAccessToken, SearchSpace, User
|
||||
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
||||
from app.utils.rbac import check_search_space_access, get_allowed_read_space_ids
|
||||
from app.db import PersonalAccessToken, User, Workspace
|
||||
from app.routes.workspaces_routes import create_default_roles_and_membership
|
||||
from app.utils.rbac import check_workspace_access, get_allowed_read_space_ids
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -30,8 +30,8 @@ async def _space_with_membership(
|
|||
user: User,
|
||||
*,
|
||||
api_access_enabled: bool,
|
||||
) -> SearchSpace:
|
||||
space = SearchSpace(
|
||||
) -> Workspace:
|
||||
space = Workspace(
|
||||
name="Zero Authz Space",
|
||||
user_id=user.id,
|
||||
api_access_enabled=api_access_enabled,
|
||||
|
|
@ -43,10 +43,10 @@ async def _space_with_membership(
|
|||
return space
|
||||
|
||||
|
||||
async def test_zero_read_set_matches_session_search_space_access(
|
||||
async def test_zero_read_set_matches_session_workspace_access(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
disabled_space = await _space_with_membership(
|
||||
db_session,
|
||||
|
|
@ -57,17 +57,17 @@ async def test_zero_read_set_matches_session_search_space_access(
|
|||
|
||||
allowed_ids = set(await get_allowed_read_space_ids(db_session, session_auth))
|
||||
|
||||
for space in (db_search_space, disabled_space):
|
||||
membership = await check_search_space_access(db_session, session_auth, space.id)
|
||||
assert membership.search_space_id in allowed_ids
|
||||
for space in (db_workspace, disabled_space):
|
||||
membership = await check_workspace_access(db_session, session_auth, space.id)
|
||||
assert membership.workspace_id in allowed_ids
|
||||
|
||||
|
||||
async def test_zero_read_set_applies_pat_api_access_gate(
|
||||
db_session: AsyncSession,
|
||||
db_user: User,
|
||||
db_search_space: SearchSpace,
|
||||
db_workspace: Workspace,
|
||||
):
|
||||
db_search_space.api_access_enabled = True
|
||||
db_workspace.api_access_enabled = True
|
||||
disabled_space = await _space_with_membership(
|
||||
db_session,
|
||||
db_user,
|
||||
|
|
@ -78,8 +78,8 @@ async def test_zero_read_set_applies_pat_api_access_gate(
|
|||
|
||||
allowed_ids = set(await get_allowed_read_space_ids(db_session, pat_auth))
|
||||
|
||||
assert db_search_space.id in allowed_ids
|
||||
assert db_workspace.id in allowed_ids
|
||||
assert disabled_space.id not in allowed_ids
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_search_space_access(db_session, pat_auth, disabled_space.id)
|
||||
await check_workspace_access(db_session, pat_auth, disabled_space.id)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
"""Tests for the shared ``web_search`` tool's citable-result adaptation.
|
||||
|
||||
The tool's network path (SearXNG + live connectors) is out of scope here; these
|
||||
cover the pure mapping from raw web results to renderable, citable documents and
|
||||
the end-to-end registration of ``WEB_RESULT`` ``[n]`` labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import (
|
||||
CitationRegistry,
|
||||
CitationSourceType,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.document_render import render_web_results
|
||||
from app.agents.chat.shared.tools.web_search import (
|
||||
_to_renderable_web_documents,
|
||||
_web_source_label,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _raw_result(url: str, title: str, content: str) -> dict:
|
||||
return {
|
||||
"document": {"title": title, "metadata": {"url": url}},
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def test_web_source_label_strips_scheme_and_www() -> None:
|
||||
assert _web_source_label("https://www.example.com/path") == "Web · example.com"
|
||||
assert _web_source_label("http://news.site.org/a/b") == "Web · news.site.org"
|
||||
assert _web_source_label("") == "Web"
|
||||
|
||||
|
||||
def test_adapter_maps_each_result_to_one_web_passage() -> None:
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("https://a.com/x", "Alpha", "alpha body"),
|
||||
_raw_result("https://b.com/y", "Beta", "beta body"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [d.title for d in docs] == ["Alpha", "Beta"]
|
||||
passages = [p for d in docs for p in d.passages]
|
||||
assert all(p.source_type is CitationSourceType.WEB_RESULT for p in passages)
|
||||
assert passages[0].locator == {"url": "https://a.com/x"}
|
||||
assert passages[0].content == "alpha body"
|
||||
|
||||
|
||||
def test_adapter_skips_results_without_url_or_content() -> None:
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("", "No URL", "has content"),
|
||||
_raw_result("https://c.com/z", "Empty", " "),
|
||||
_raw_result("https://d.com/w", "Good", "real content"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [d.title for d in docs] == ["Good"]
|
||||
|
||||
|
||||
def test_adapter_truncates_on_char_budget() -> None:
|
||||
big = "x" * 30
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("https://a.com", "A", big),
|
||||
_raw_result("https://b.com", "B", big),
|
||||
_raw_result("https://c.com", "C", big),
|
||||
],
|
||||
max_chars=50,
|
||||
)
|
||||
|
||||
# First fits (30), second crosses 50 and stops the loop.
|
||||
assert [d.title for d in docs] == ["A"]
|
||||
|
||||
|
||||
def test_end_to_end_registers_web_results_for_citation() -> None:
|
||||
registry = CitationRegistry()
|
||||
docs = _to_renderable_web_documents(
|
||||
[_raw_result("https://example.com/a", "Example", "the answer is 42")]
|
||||
)
|
||||
|
||||
block = render_web_results(docs, registry)
|
||||
|
||||
assert block is not None
|
||||
assert "[1] the answer is 42" in block
|
||||
entry = registry.resolve(1)
|
||||
assert entry is not None
|
||||
assert entry.source_type is CitationSourceType.WEB_RESULT
|
||||
assert entry.locator == {"url": "https://example.com/a"}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"""Tests for the ``<web_results>`` wrapper around web-result excerpt documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import (
|
||||
CitationRegistry,
|
||||
CitationSourceType,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.document_render import (
|
||||
RenderableDocument,
|
||||
RenderablePassage,
|
||||
render_web_results,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _web_doc(url: str, title: str, content: str) -> RenderableDocument:
|
||||
return RenderableDocument(
|
||||
title=title,
|
||||
source=f"Web · {url.split('//', 1)[-1].split('/', 1)[0]}",
|
||||
passages=[
|
||||
RenderablePassage(
|
||||
content=content,
|
||||
locator={"url": url},
|
||||
source_type=CitationSourceType.WEB_RESULT,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_returns_none_when_nothing_to_show() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
assert render_web_results([], registry) is None
|
||||
|
||||
|
||||
def test_wraps_in_web_results_container() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
block = render_web_results(
|
||||
[_web_doc("https://example.com/a", "Example", "the answer is 42")],
|
||||
registry,
|
||||
)
|
||||
|
||||
assert block is not None
|
||||
assert block.startswith("<web_results>")
|
||||
assert block.endswith("</web_results>")
|
||||
assert "cite a result with its [n]" in block
|
||||
assert (
|
||||
'<document title="Example" source="Web · example.com" view="excerpt">' in block
|
||||
)
|
||||
assert "[1] the answer is 42" in block
|
||||
|
||||
|
||||
def test_registers_each_result_as_web_result_with_url_locator() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
render_web_results(
|
||||
[
|
||||
_web_doc("https://a.com/x", "A", "alpha"),
|
||||
_web_doc("https://b.com/y", "B", "beta"),
|
||||
],
|
||||
registry,
|
||||
)
|
||||
|
||||
first = registry.resolve(1)
|
||||
second = registry.resolve(2)
|
||||
assert first is not None and second is not None
|
||||
assert first.source_type is CitationSourceType.WEB_RESULT
|
||||
assert first.locator == {"url": "https://a.com/x"}
|
||||
assert second.locator == {"url": "https://b.com/y"}
|
||||
|
||||
|
||||
def test_same_url_reuses_label_across_calls() -> None:
|
||||
registry = CitationRegistry()
|
||||
doc = _web_doc("https://example.com/a", "Example", "stable fact")
|
||||
|
||||
render_web_results([doc], registry)
|
||||
render_web_results([doc], registry)
|
||||
|
||||
assert registry.next_n == 2
|
||||
|
|
@ -25,6 +25,7 @@ from app.agents.chat.multi_agent_chat.shared.permissions.middleware.core import
|
|||
PermissionMiddleware,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||
append_today_utc,
|
||||
pack_subagent,
|
||||
)
|
||||
|
||||
|
|
@ -104,6 +105,35 @@ async def test_subagent_recovers_when_primary_llm_fails():
|
|||
assert final.content == "recovered via fallback"
|
||||
|
||||
|
||||
def test_packed_subagent_prompt_carries_todays_utc_date():
|
||||
"""Subagents must inherit the main agent's clock, not guess the date."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
|
||||
result = pack_subagent(
|
||||
name="date_test",
|
||||
description="test",
|
||||
system_prompt="be helpful",
|
||||
tools=[],
|
||||
ruleset=Ruleset(origin="date_test", rules=[]),
|
||||
dependencies={"flags": AgentFeatureFlags()},
|
||||
)
|
||||
|
||||
prompt = result.spec["system_prompt"]
|
||||
assert prompt.startswith("be helpful")
|
||||
assert f"Today (UTC): {today}" in prompt
|
||||
|
||||
|
||||
def test_append_today_utc_is_idempotent_shape():
|
||||
"""Helper appends exactly one dated line and preserves the original body."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
out = append_today_utc("body")
|
||||
assert out == f"body\n\nToday (UTC): {today}\n"
|
||||
|
||||
|
||||
def _extract_permission_mw(spec) -> PermissionMiddleware:
|
||||
"""Find the lone PermissionMiddleware in a subagent's middleware list."""
|
||||
matches = [m for m in spec["middleware"] if isinstance(m, PermissionMiddleware)]
|
||||
|
|
@ -191,25 +221,25 @@ def test_missing_user_allowlist_keeps_coded_behaviour():
|
|||
|
||||
|
||||
def test_user_allowlist_for_different_subagent_does_not_leak():
|
||||
"""User trust for ``linear`` must not affect a ``jira`` subagent compile."""
|
||||
"""User trust for one subagent must not affect a different subagent compile."""
|
||||
coded = Ruleset(
|
||||
origin="jira",
|
||||
origin="mcp_discovery",
|
||||
rules=[Rule(permission="save_issue", pattern="*", action="ask")],
|
||||
)
|
||||
linear_allowlist = Ruleset(
|
||||
origin="user_allowlist:linear",
|
||||
other_allowlist = Ruleset(
|
||||
origin="user_allowlist:knowledge_base",
|
||||
rules=[Rule(permission="save_issue", pattern="*", action="allow")],
|
||||
)
|
||||
|
||||
result = pack_subagent(
|
||||
name="jira",
|
||||
name="mcp_discovery",
|
||||
description="test",
|
||||
system_prompt="x",
|
||||
tools=[],
|
||||
ruleset=coded,
|
||||
dependencies={
|
||||
"flags": AgentFeatureFlags(),
|
||||
"user_allowlist_by_subagent": {"linear": linear_allowlist},
|
||||
"user_allowlist_by_subagent": {"knowledge_base": other_allowlist},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"""Allowlist filtering in ``_load_http_mcp_tools``.
|
||||
|
||||
Covers the fallback added after Notion renamed its MCP tools ("notion-"
|
||||
prefix) and a stale allowlist silently disabled the connector: when the
|
||||
allowlist matches zero advertised tools, load everything (HITL-gated)
|
||||
instead of nothing.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.cache import (
|
||||
CachedMCPTools,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
||||
_load_http_mcp_tools,
|
||||
)
|
||||
|
||||
_CACHED = CachedMCPTools(
|
||||
discovered_at=datetime.now(UTC),
|
||||
tools=[
|
||||
{"name": "notion-search", "description": "search", "input_schema": {}},
|
||||
{"name": "notion-update-page", "description": "write", "input_schema": {}},
|
||||
],
|
||||
)
|
||||
_SERVER_CONFIG = {"url": "https://example.com/mcp", "headers": {}}
|
||||
|
||||
|
||||
async def test_allowlist_match_filters_and_flags_readonly():
|
||||
tools = await _load_http_mcp_tools(
|
||||
1,
|
||||
"Notion",
|
||||
_SERVER_CONFIG,
|
||||
allowed_tools=["notion-search"],
|
||||
readonly_tools=frozenset({"notion-search"}),
|
||||
cached_tools=_CACHED,
|
||||
)
|
||||
assert [t.name for t in tools] == ["notion-search"]
|
||||
assert tools[0].metadata["hitl"] is False
|
||||
|
||||
|
||||
async def test_stale_allowlist_falls_back_to_all_tools_hitl_gated():
|
||||
tools = await _load_http_mcp_tools(
|
||||
1,
|
||||
"Notion",
|
||||
_SERVER_CONFIG,
|
||||
allowed_tools=["search", "update-page"], # server renamed everything
|
||||
readonly_tools=frozenset({"search"}),
|
||||
cached_tools=_CACHED,
|
||||
)
|
||||
assert sorted(t.name for t in tools) == ["notion-search", "notion-update-page"]
|
||||
# Renamed tools match no readonly entry -> every tool requires approval.
|
||||
assert all(t.metadata["hitl"] is True for t in tools)
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"""Guardrails for the MCP-consolidation migration.
|
||||
|
||||
Every MCP-backed connector now routes to the single ``mcp_discovery`` subagent
|
||||
(file connectors stay native; Discord/Teams/Luma are deprecated). These tests
|
||||
pin the pieces that make that safe: connector→route mapping, any-of gating,
|
||||
legacy checkpoint aliasing, tool-name collision prefixing, the metadata-derived
|
||||
approval ruleset, and the KB indexing-deprecation sets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
from app.agents.chat.multi_agent_chat.constants import (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
|
||||
LEGACY_SUBAGENT_ALIASES,
|
||||
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.index import (
|
||||
NAME as MCP_DISCOVERY_NAME,
|
||||
build_ruleset,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.mcp_tools.index import (
|
||||
resolve_tool_name_collisions,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.registry import (
|
||||
SUBAGENT_BUILDERS_BY_NAME,
|
||||
get_subagents_to_exclude,
|
||||
)
|
||||
from app.services.mcp_oauth.registry import (
|
||||
DEPRECATED_INDEXING_CONNECTOR_TYPES,
|
||||
LIVE_CONNECTOR_TYPES,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# Connectors that must all funnel into ``mcp_discovery`` (not their own routes).
|
||||
_MCP_ROUTED = {
|
||||
"SLACK_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"MCP_CONNECTOR",
|
||||
}
|
||||
|
||||
|
||||
def _tool(name: str, metadata: dict) -> StructuredTool:
|
||||
return StructuredTool.from_function(
|
||||
func=lambda: name,
|
||||
name=name,
|
||||
description=name,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_all_mcp_connectors_route_to_discovery():
|
||||
for connector_type in _MCP_ROUTED:
|
||||
assert (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS[connector_type] == MCP_DISCOVERY_NAME
|
||||
), connector_type
|
||||
|
||||
|
||||
def test_file_connectors_keep_native_routes():
|
||||
assert (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["GOOGLE_DRIVE_CONNECTOR"]
|
||||
== "google_drive"
|
||||
)
|
||||
assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["DROPBOX_CONNECTOR"] == "dropbox"
|
||||
assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["ONEDRIVE_CONNECTOR"] == "onedrive"
|
||||
|
||||
|
||||
def test_deprecated_connectors_have_no_route():
|
||||
for connector_type in ("DISCORD_CONNECTOR", "TEAMS_CONNECTOR", "LUMA_CONNECTOR"):
|
||||
assert connector_type not in CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS
|
||||
|
||||
|
||||
def test_discovery_gating_is_any_of():
|
||||
"""One connected app is enough to keep ``mcp_discovery``; none excludes it."""
|
||||
assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["SLACK_CONNECTOR"])
|
||||
assert MCP_DISCOVERY_NAME in get_subagents_to_exclude([])
|
||||
# A generic user MCP server alone still unlocks it.
|
||||
assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["MCP_CONNECTOR"])
|
||||
|
||||
|
||||
def test_discovery_gating_tokens_match_routed_connectors():
|
||||
assert SUBAGENT_TO_REQUIRED_CONNECTOR_MAP[MCP_DISCOVERY_NAME] == frozenset(
|
||||
_MCP_ROUTED
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_aliases_resolve_to_a_live_subagent():
|
||||
"""Old per-connector task names must alias onto a subagent that still exists."""
|
||||
for legacy, target in LEGACY_SUBAGENT_ALIASES.items():
|
||||
assert legacy not in SUBAGENT_BUILDERS_BY_NAME, legacy
|
||||
assert target in SUBAGENT_BUILDERS_BY_NAME, target
|
||||
|
||||
|
||||
def test_collision_only_prefixes_shared_names():
|
||||
"""A name on two connectors is prefixed; a unique name is left untouched."""
|
||||
tools = [
|
||||
_tool("search", {"mcp_connector_id": 1, "mcp_transport": "http"}),
|
||||
_tool("search", {"mcp_connector_id": 2, "mcp_transport": "http"}),
|
||||
_tool("list_bases", {"mcp_connector_id": 2, "mcp_transport": "http"}),
|
||||
]
|
||||
resolved = {
|
||||
t.name: t
|
||||
for t in resolve_tool_name_collisions(
|
||||
tools, {1: "NOTION_CONNECTOR", 2: "AIRTABLE_CONNECTOR"}
|
||||
)
|
||||
}
|
||||
|
||||
# The unique tool keeps its bare name (trusted_tools / history stay valid).
|
||||
assert "list_bases" in resolved
|
||||
# The colliding name is gone; both are prefixed with service + connector id.
|
||||
assert "search" not in resolved
|
||||
assert "notion_1_search" in resolved
|
||||
assert "airtable_2_search" in resolved
|
||||
# Original name preserved for the "Always Allow" fallback key.
|
||||
for name in ("notion_1_search", "airtable_2_search"):
|
||||
meta = resolved[name].metadata or {}
|
||||
assert meta["mcp_original_tool_name"] == "search"
|
||||
assert meta["mcp_collision_prefixed"] is True
|
||||
|
||||
|
||||
def test_collision_noop_without_collisions():
|
||||
tools = [
|
||||
_tool("a", {"mcp_connector_id": 1}),
|
||||
_tool("b", {"mcp_connector_id": 2}),
|
||||
]
|
||||
assert [t.name for t in resolve_tool_name_collisions(tools, {})] == ["a", "b"]
|
||||
|
||||
|
||||
def test_ruleset_reads_hitl_from_metadata():
|
||||
"""Read-only MCP tools ``allow``; every other MCP tool ``ask``; natives skip."""
|
||||
tools = [
|
||||
_tool("readonly_search", {"mcp_transport": "http", "hitl": False}),
|
||||
_tool("mutating_create", {"mcp_transport": "http", "hitl": True}),
|
||||
_tool("native_helper", {}), # no mcp_transport => no rule
|
||||
]
|
||||
rules = {r.permission: r.action for r in build_ruleset(tools).rules}
|
||||
assert rules == {"readonly_search": "allow", "mutating_create": "ask"}
|
||||
|
||||
|
||||
def test_indexing_deprecation_sets():
|
||||
"""Indexing-only connectors are deprecated; migrated ones are LIVE; Obsidian stays."""
|
||||
deprecated = {t.value for t in DEPRECATED_INDEXING_CONNECTOR_TYPES}
|
||||
assert deprecated == {
|
||||
"GITHUB_CONNECTOR",
|
||||
"BOOKSTACK_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"CIRCLEBACK_CONNECTOR",
|
||||
}
|
||||
assert "OBSIDIAN_CONNECTOR" not in deprecated
|
||||
|
||||
live = {t.value for t in LIVE_CONNECTOR_TYPES}
|
||||
assert {"NOTION_CONNECTOR", "CONFLUENCE_CONNECTOR"} <= live
|
||||
|
|
@ -45,7 +45,7 @@ def test_every_subagent_has_description_md(name: str):
|
|||
[
|
||||
"core_behavior.md",
|
||||
"routing.md",
|
||||
"tools/web_search/description.md",
|
||||
"tools/task/description.md",
|
||||
],
|
||||
)
|
||||
def test_main_agent_prompt_fragments_resolve(filename: str):
|
||||
|
|
|
|||
|
|
@ -19,36 +19,32 @@ from app.agents.chat.multi_agent_chat.subagents.registry import (
|
|||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# The full specialist roster the main agent composes from: 4 builtins + 15
|
||||
# connector routes. Adding/removing a specialist is a deliberate product change
|
||||
# and must be reflected here.
|
||||
# The full specialist roster the main agent composes from after the MCP
|
||||
# migration: builtins + the three file-connector routes (Drive/Dropbox/
|
||||
# OneDrive). Every MCP-backed connector (Slack/Jira/Linear/ClickUp/Airtable/
|
||||
# Notion/Confluence/Gmail/Calendar) now lives behind the single
|
||||
# ``mcp_discovery`` route; Discord/Teams/Luma were deprecated. Adding/removing a
|
||||
# specialist is a deliberate product change and must be reflected here.
|
||||
_EXPECTED_SUBAGENTS = frozenset(
|
||||
{
|
||||
"airtable",
|
||||
"calendar",
|
||||
"clickup",
|
||||
"confluence",
|
||||
"deliverables",
|
||||
"discord",
|
||||
"dropbox",
|
||||
"gmail",
|
||||
"google_drive",
|
||||
"jira",
|
||||
"google_maps",
|
||||
"google_search",
|
||||
"knowledge_base",
|
||||
"linear",
|
||||
"luma",
|
||||
"mcp_discovery",
|
||||
"memory",
|
||||
"notion",
|
||||
"onedrive",
|
||||
"research",
|
||||
"slack",
|
||||
"teams",
|
||||
"reddit",
|
||||
"web_crawler",
|
||||
"youtube",
|
||||
}
|
||||
)
|
||||
|
||||
# Specialists that are always available regardless of connected sources, so they
|
||||
# carry no required-connector entry.
|
||||
_CONNECTORLESS = frozenset({"memory", "research"})
|
||||
_CONNECTORLESS = frozenset({"memory"})
|
||||
|
||||
|
||||
def test_registry_contains_exactly_expected_subagents():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"""Guardrail: the multi-engine ``web_search`` tool is fully retired.
|
||||
|
||||
Public web search now runs exclusively through the ``google_search`` subagent,
|
||||
and the four search connector types (Tavily/SearXNG/Linkup/Baidu) are soft-
|
||||
deprecated. This pins those invariants so a future change can't quietly bring
|
||||
the ``web_search`` tool back or un-deprecate a search connector.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.agents.chat.multi_agent_chat.main_agent.tools.index import (
|
||||
MAIN_AGENT_SURFSENSE_TOOL_NAMES,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.registry import (
|
||||
SUBAGENT_BUILDERS_BY_NAME,
|
||||
)
|
||||
from app.utils.validators import (
|
||||
DEPRECATED_CONNECTOR_TYPES,
|
||||
raise_if_connector_deprecated,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_DEPRECATED_SEARCH_TYPES = (
|
||||
"TAVILY_API",
|
||||
"SEARXNG_API",
|
||||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
)
|
||||
|
||||
|
||||
def test_web_search_tool_removed_from_main_agent():
|
||||
assert "web_search" not in MAIN_AGENT_SURFSENSE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_google_search_specialist_present():
|
||||
assert "google_search" in SUBAGENT_BUILDERS_BY_NAME
|
||||
|
||||
|
||||
@pytest.mark.parametrize("connector_type", _DEPRECATED_SEARCH_TYPES)
|
||||
def test_search_connectors_deprecated(connector_type):
|
||||
assert connector_type in DEPRECATED_CONNECTOR_TYPES
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
raise_if_connector_deprecated(connector_type)
|
||||
assert excinfo.value.status_code == 410
|
||||
|
|
@ -94,7 +94,7 @@ def fake_session_factory():
|
|||
class TestActionLogMiddlewareDisabled:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_flag_off(self, patch_get_flags) -> None:
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -110,7 +110,7 @@ class TestActionLogMiddlewareDisabled:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_thread_id_none(self, patch_get_flags) -> None:
|
||||
mw = ActionLogMiddleware(thread_id=None, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=None, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -126,7 +126,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -150,7 +150,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
assert len(captured["rows"]) == 1
|
||||
row = captured["rows"][0]
|
||||
assert row.thread_id == 42
|
||||
assert row.search_space_id == 7
|
||||
assert row.workspace_id == 7
|
||||
assert row.user_id == "u1"
|
||||
assert row.tool_name == "make_widget"
|
||||
assert row.args == {"color": "red", "size": 3}
|
||||
|
|
@ -170,7 +170,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
) -> None:
|
||||
"""``chat_turn_id`` falls back to NULL when ``runtime.config`` is absent."""
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc-1"},
|
||||
runtime=None,
|
||||
|
|
@ -190,7 +190,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {"color": "red"}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -214,7 +214,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags
|
||||
) -> None:
|
||||
"""Even if the DB write blows up, the tool's result must reach the model."""
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -250,7 +250,7 @@ class TestReverseDescriptor:
|
|||
)
|
||||
mw = ActionLogMiddleware(
|
||||
thread_id=1,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id="u",
|
||||
tool_definitions={"make_widget": tool_def},
|
||||
)
|
||||
|
|
@ -296,7 +296,7 @@ class TestReverseDescriptor:
|
|||
)
|
||||
mw = ActionLogMiddleware(
|
||||
thread_id=1,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id=None,
|
||||
tool_definitions={"make_widget": tool_def},
|
||||
)
|
||||
|
|
@ -321,7 +321,7 @@ class TestReverseDescriptor:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "unknown_tool", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -343,7 +343,7 @@ class TestActionLogDispatch:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
_captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -383,7 +383,7 @@ class TestActionLogDispatch:
|
|||
@pytest.mark.asyncio
|
||||
async def test_no_dispatch_when_persistence_fails(self, patch_get_flags) -> None:
|
||||
"""If commit fails the dispatch is suppressed (no row to surface)."""
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -411,7 +411,7 @@ class TestArgsTruncation:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
# Build a > 32KB string so the persisted payload triggers the truncation path.
|
||||
huge = "x" * (40 * 1024)
|
||||
request = _FakeRequest(
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class TestSpillEdit:
|
|||
assert edit.pending_spills == []
|
||||
|
||||
def test_above_trigger_clears_and_records(self) -> None:
|
||||
edit = SpillToBackendEdit(trigger=100, keep=1, path_prefix="/tool_outputs")
|
||||
edit = SpillToBackendEdit(trigger=100, keep=1)
|
||||
msgs = _build_history(4)
|
||||
edit.apply(msgs, count_tokens=_approx_count)
|
||||
|
||||
|
|
@ -102,7 +102,9 @@ class TestSpillEdit:
|
|||
assert edit.drain_pending() == []
|
||||
|
||||
def test_placeholder_format(self) -> None:
|
||||
path = "/tool_outputs/thread-1/tool-msg-0.txt"
|
||||
text = _build_spill_placeholder(path)
|
||||
assert path in text
|
||||
assert "explore" in text # mentions the recovery agent
|
||||
import uuid
|
||||
|
||||
spill_id = uuid.uuid4()
|
||||
text = _build_spill_placeholder(spill_id)
|
||||
assert f"spill_{spill_id}" in text
|
||||
assert "read_run" in text # points at the recovery tools
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ earliest -> latest:
|
|||
3. (future) user-defined rules from the Agent Permissions UI
|
||||
|
||||
Without #1 every read-only built-in (``ls``, ``read_file``, ``grep``,
|
||||
``glob``, ``web_search`` …) defaulted to ``ask`` because
|
||||
``glob`` …) defaulted to ``ask`` because
|
||||
``permissions.evaluate`` returns ``ask`` when no rule matches. That
|
||||
caused two production-painful behaviors:
|
||||
|
||||
|
|
@ -58,8 +58,6 @@ class TestReadOnlyToolsAllowed:
|
|||
"read_file",
|
||||
"grep",
|
||||
"glob",
|
||||
"web_search",
|
||||
"scrape_webpage",
|
||||
"get_connected_accounts",
|
||||
"write_todos",
|
||||
"task",
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class TestResolveMentions:
|
|||
session.execute = AsyncMock()
|
||||
result = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=None,
|
||||
)
|
||||
assert isinstance(result, ResolvedMentionSet)
|
||||
|
|
@ -134,7 +134,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert len(out.mentions) == 1
|
||||
|
|
@ -170,7 +170,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=3,
|
||||
workspace_id=3,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert len(out.mentions) == 1
|
||||
|
|
@ -201,7 +201,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert out.mentions == []
|
||||
|
|
@ -238,7 +238,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=[chip_short, chip_long],
|
||||
)
|
||||
tokens = [tok for tok, _ in out.token_to_path]
|
||||
|
|
@ -265,7 +265,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=2,
|
||||
workspace_id=2,
|
||||
mentioned_documents=None,
|
||||
mentioned_document_ids=[7],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ class TestResolveToolName:
|
|||
def test_falls_back_to_tool_call_name(self) -> None:
|
||||
request = MagicMock()
|
||||
request.tool = None
|
||||
request.tool_call = {"name": "web_search", "args": {}}
|
||||
assert _resolve_tool_name(request) == "web_search"
|
||||
request.tool_call = {"name": "create_automation", "args": {}}
|
||||
assert _resolve_tool_name(request) == "create_automation"
|
||||
|
||||
def test_unknown_when_nothing_resolves(self) -> None:
|
||||
request = MagicMock()
|
||||
|
|
@ -278,8 +278,8 @@ class TestMiddlewareIntegration:
|
|||
|
||||
request = MagicMock()
|
||||
request.tool = MagicMock()
|
||||
request.tool.name = "web_search"
|
||||
request.tool.name = "scrape_webpage"
|
||||
await mw.awrap_tool_call(request, handler)
|
||||
assert errors == ["web_search"]
|
||||
assert errors == ["scrape_webpage"]
|
||||
finally:
|
||||
ot.reload_for_tests()
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/{encoded_basename}",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -169,7 +169,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/Calendar_ Happy birthday!.xml",
|
||||
)
|
||||
assert document is None
|
||||
|
|
@ -191,7 +191,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/Plain Note.xml",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -217,7 +217,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf.xml",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -239,7 +239,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class _DummyMiddleware(AgentMiddleware):
|
|||
|
||||
def _ctx() -> PluginContext:
|
||||
return PluginContext.build(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id="u",
|
||||
thread_visibility="PRIVATE", # type: ignore[arg-type]
|
||||
llm=MagicMock(),
|
||||
|
|
@ -165,12 +165,12 @@ class TestPluginContext:
|
|||
def test_build_includes_required_fields(self) -> None:
|
||||
llm = MagicMock()
|
||||
ctx = PluginContext.build(
|
||||
search_space_id=42,
|
||||
workspace_id=42,
|
||||
user_id="user-1",
|
||||
thread_visibility="PRIVATE", # type: ignore[arg-type]
|
||||
llm=llm,
|
||||
)
|
||||
assert ctx["search_space_id"] == 42
|
||||
assert ctx["workspace_id"] == 42
|
||||
assert ctx["user_id"] == "user-1"
|
||||
assert ctx["llm"] is llm
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.agents.chat.multi_agent_chat.main_agent.skills.backends import (
|
|||
SKILLS_BUILTIN_PREFIX,
|
||||
SKILLS_SPACE_PREFIX,
|
||||
BuiltinSkillsBackend,
|
||||
SearchSpaceSkillsBackend,
|
||||
WorkspaceSkillsBackend,
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
|
|
@ -176,7 +176,7 @@ class _FakeKBBackend:
|
|||
return out
|
||||
|
||||
|
||||
class TestSearchSpaceSkillsBackend:
|
||||
class TestWorkspaceSkillsBackend:
|
||||
def test_remaps_paths_when_listing(self) -> None:
|
||||
listing = [
|
||||
{"path": "/documents/_skills/policy", "is_dir": True},
|
||||
|
|
@ -184,7 +184,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
{"path": "/documents/other-folder/x.md", "is_dir": False},
|
||||
]
|
||||
kb = _FakeKBBackend(listing=listing, file_contents={})
|
||||
backend = SearchSpaceSkillsBackend(kb)
|
||||
backend = WorkspaceSkillsBackend(kb)
|
||||
infos = asyncio.run(backend.als_info("/"))
|
||||
assert kb.last_ls_path == "/documents/_skills"
|
||||
paths = [info["path"] for info in infos]
|
||||
|
|
@ -200,7 +200,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
"/documents/_skills/policy/SKILL.md": b"---\nname: policy\n---\n",
|
||||
},
|
||||
)
|
||||
backend = SearchSpaceSkillsBackend(kb)
|
||||
backend = WorkspaceSkillsBackend(kb)
|
||||
responses = asyncio.run(backend.adownload_files(["/policy/SKILL.md"]))
|
||||
assert kb.last_download_paths == ["/documents/_skills/policy/SKILL.md"]
|
||||
assert responses[0].path == "/policy/SKILL.md"
|
||||
|
|
@ -208,7 +208,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
assert responses[0].content is not None
|
||||
|
||||
def test_sync_methods_raise_not_implemented(self) -> None:
|
||||
backend = SearchSpaceSkillsBackend(_FakeKBBackend([], {}))
|
||||
backend = WorkspaceSkillsBackend(_FakeKBBackend([], {}))
|
||||
with pytest.raises(NotImplementedError):
|
||||
backend.ls_info("/")
|
||||
with pytest.raises(NotImplementedError):
|
||||
|
|
@ -221,7 +221,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
],
|
||||
file_contents={},
|
||||
)
|
||||
backend = SearchSpaceSkillsBackend(kb, kb_root="/skills_admin")
|
||||
backend = WorkspaceSkillsBackend(kb, kb_root="/skills_admin")
|
||||
infos = asyncio.run(backend.als_info("/"))
|
||||
assert kb.last_ls_path == "/skills_admin"
|
||||
assert infos[0]["path"] == "/x"
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ async def test_generate_resume_defaults_to_one_page_target(monkeypatch) -> None:
|
|||
monkeypatch.setattr(resume_tool, "_compile_typst", lambda _source: b"pdf")
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: 1)
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience"})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -178,7 +178,7 @@ async def test_generate_resume_compresses_when_over_limit(monkeypatch) -> None:
|
|||
page_counts = iter([2, 1])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -213,7 +213,7 @@ async def test_generate_resume_returns_ready_when_target_not_met(monkeypatch) ->
|
|||
page_counts = iter([3, 3, 2])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -246,7 +246,7 @@ async def test_generate_resume_fails_when_hard_limit_exceeded(monkeypatch) -> No
|
|||
page_counts = iter([7, 6, 6])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "failed"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Lock the runtime model-policy backstop in ``build_dependencies``.
|
||||
|
||||
Automations resolve their LLM from the *captured* ``chat_model_id`` snapshot (so
|
||||
runs are insulated from later chat/search-space model changes), and the model
|
||||
runs are insulated from later chat/workspace model changes), and the model
|
||||
policy is re-checked at run time so a captured model that is no longer billable
|
||||
fails the run clearly. When no snapshot is present, resolution falls back to the
|
||||
live search space.
|
||||
live workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -25,66 +25,66 @@ pytestmark = pytest.mark.unit
|
|||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal async session whose ``get`` returns a preset search space."""
|
||||
"""Minimal async session whose ``get`` returns a preset workspace."""
|
||||
|
||||
def __init__(self, search_space: Any) -> None:
|
||||
self._search_space = search_space
|
||||
def __init__(self, workspace: Any) -> None:
|
||||
self._workspace = workspace
|
||||
|
||||
async def get(self, _model: Any, _pk: int) -> Any:
|
||||
return self._search_space
|
||||
return self._workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Stub the connector setup + checkpointer so only policy/LLM logic runs."""
|
||||
|
||||
async def _fake_setup(_session, *, search_space_id):
|
||||
return (SimpleNamespace(name="connector"), "fc-key")
|
||||
async def _fake_setup(_session, *, workspace_id):
|
||||
return SimpleNamespace(name="connector")
|
||||
|
||||
monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup)
|
||||
monkeypatch.setattr(deps_mod, "setup_connector_service", _fake_setup)
|
||||
return None
|
||||
|
||||
|
||||
async def test_build_dependencies_resolves_captured_chat_model_id(
|
||||
monkeypatch: pytest.MonkeyPatch, patched_side_effects
|
||||
) -> None:
|
||||
"""The bundle loads with the *captured* ``chat_model_id``, not the live search space."""
|
||||
"""The bundle loads with the *captured* ``chat_model_id``, not the live workspace."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _fake_load(_session, *, config_id, search_space_id):
|
||||
async def _fake_load(_session, *, config_id, workspace_id):
|
||||
captured["config_id"] = config_id
|
||||
captured["search_space_id"] = search_space_id
|
||||
captured["workspace_id"] = workspace_id
|
||||
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
|
||||
|
||||
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
|
||||
# Captured path validates the explicit ids; passes for this test.
|
||||
monkeypatch.setattr(deps_mod, "assert_models_billable", lambda **_kw: None)
|
||||
# A different value on the live search space proves we ignore it when a
|
||||
# A different value on the live workspace proves we ignore it when a
|
||||
# snapshot is supplied.
|
||||
monkeypatch.setattr(
|
||||
deps_mod,
|
||||
"assert_automation_models_billable",
|
||||
lambda _ss: pytest.fail("search-space policy should not run on captured path"),
|
||||
lambda _ss: pytest.fail("workspace policy should not run on captured path"),
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(chat_model_id=-99)
|
||||
workspace = SimpleNamespace(chat_model_id=-99)
|
||||
result = await build_dependencies(
|
||||
session=_FakeSession(search_space),
|
||||
search_space_id=42,
|
||||
session=_FakeSession(workspace),
|
||||
workspace_id=42,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
|
||||
assert captured == {"config_id": -7, "search_space_id": 42}
|
||||
assert captured == {"config_id": -7, "workspace_id": 42}
|
||||
assert result.llm.name == "llm"
|
||||
assert result.firecrawl_api_key == "fc-key"
|
||||
assert result.connector_service.name == "connector"
|
||||
|
||||
|
||||
async def test_build_dependencies_validates_captured_ids(
|
||||
monkeypatch: pytest.MonkeyPatch, patched_side_effects
|
||||
) -> None:
|
||||
"""The captured ids (not the search space) are what gets policy-checked."""
|
||||
"""The captured ids (not the workspace) are what gets policy-checked."""
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
|
|
@ -92,14 +92,14 @@ async def test_build_dependencies_validates_captured_ids(
|
|||
|
||||
monkeypatch.setattr(deps_mod, "assert_models_billable", _capture)
|
||||
|
||||
async def _fake_load(_session, *, config_id, search_space_id):
|
||||
async def _fake_load(_session, *, config_id, workspace_id):
|
||||
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
|
||||
|
||||
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
|
||||
|
||||
await build_dependencies(
|
||||
session=_FakeSession(SimpleNamespace(chat_model_id=0)),
|
||||
search_space_id=42,
|
||||
workspace_id=42,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
|
|
@ -132,20 +132,20 @@ async def test_build_dependencies_raises_on_captured_policy_violation(
|
|||
with pytest.raises(DependencyError):
|
||||
await build_dependencies(
|
||||
session=_FakeSession(SimpleNamespace(chat_model_id=-7)),
|
||||
search_space_id=42,
|
||||
workspace_id=42,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=-2,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
|
||||
|
||||
async def test_build_dependencies_falls_back_to_search_space(
|
||||
async def test_build_dependencies_falls_back_to_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch, patched_side_effects
|
||||
) -> None:
|
||||
"""With no captured snapshot, resolve + validate the live search space."""
|
||||
"""With no captured snapshot, resolve + validate the live workspace."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _fake_load(_session, *, config_id, search_space_id):
|
||||
async def _fake_load(_session, *, config_id, workspace_id):
|
||||
captured["config_id"] = config_id
|
||||
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
|
||||
|
||||
|
|
@ -157,18 +157,16 @@ async def test_build_dependencies_falls_back_to_search_space(
|
|||
lambda **_kw: pytest.fail("captured policy should not run on fallback path"),
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(chat_model_id=-7)
|
||||
result = await build_dependencies(
|
||||
session=_FakeSession(search_space), search_space_id=42
|
||||
)
|
||||
workspace = SimpleNamespace(chat_model_id=-7)
|
||||
result = await build_dependencies(session=_FakeSession(workspace), workspace_id=42)
|
||||
|
||||
assert captured == {"config_id": -7}
|
||||
assert result.llm.name == "llm"
|
||||
|
||||
|
||||
async def test_build_dependencies_raises_when_search_space_missing(
|
||||
async def test_build_dependencies_raises_when_workspace_missing(
|
||||
patched_side_effects,
|
||||
) -> None:
|
||||
"""A missing search space (fallback path) surfaces as a ``DependencyError``."""
|
||||
"""A missing workspace (fallback path) surfaces as a ``DependencyError``."""
|
||||
with pytest.raises(DependencyError):
|
||||
await build_dependencies(session=_FakeSession(None), search_space_id=999)
|
||||
await build_dependencies(session=_FakeSession(None), workspace_id=999)
|
||||
|
|
|
|||
0
surfsense_backend/tests/unit/automations/api/__init__.py
Normal file
0
surfsense_backend/tests/unit/automations/api/__init__.py
Normal file
|
|
@ -34,7 +34,7 @@ def _action_context() -> ActionContext:
|
|||
session=cast(AsyncSession, None),
|
||||
run_id=1,
|
||||
step_id="s1",
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
creator_user_id=None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Lock that the executor propagates the captured model snapshot into the
|
||||
``ActionContext``, so runs resolve their own model (insulated from chat /
|
||||
search-space changes) and not the live search space.
|
||||
workspace changes) and not the live workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -21,7 +21,7 @@ pytestmark = pytest.mark.unit
|
|||
def _run() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
automation=SimpleNamespace(search_space_id=42, created_by_user_id="u-1"),
|
||||
automation=SimpleNamespace(workspace_id=42, created_by_user_id="u-1"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ def test_build_action_ctx_propagates_captured_models() -> None:
|
|||
models,
|
||||
)
|
||||
|
||||
assert ctx.search_space_id == 42
|
||||
assert ctx.workspace_id == 42
|
||||
assert ctx.chat_model_id == -1
|
||||
assert ctx.image_gen_model_id == 5
|
||||
assert ctx.vision_model_id == -1
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ _VALID_DEFINITION = {
|
|||
|
||||
|
||||
def test_automation_create_accepts_valid_minimal_payload() -> None:
|
||||
"""Happy path: just search_space_id, name, and a valid definition.
|
||||
"""Happy path: just workspace_id, name, and a valid definition.
|
||||
Triggers default to ``[]`` so users can attach them later."""
|
||||
payload = AutomationCreate.model_validate(
|
||||
{
|
||||
"search_space_id": 1,
|
||||
"workspace_id": 1,
|
||||
"name": "Daily digest",
|
||||
"definition": _VALID_DEFINITION,
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ def test_automation_create_cascades_validation_into_nested_definition() -> None:
|
|||
with pytest.raises(ValidationError):
|
||||
AutomationCreate.model_validate(
|
||||
{
|
||||
"search_space_id": 1,
|
||||
"workspace_id": 1,
|
||||
"name": "Bad",
|
||||
"definition": {"name": "X", "plan": []}, # empty plan
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ def test_automation_create_rejects_unknown_top_level_field() -> None:
|
|||
with pytest.raises(ValidationError):
|
||||
AutomationCreate.model_validate(
|
||||
{
|
||||
"search_space_id": 1,
|
||||
"workspace_id": 1,
|
||||
"name": "X",
|
||||
"definition": _VALID_DEFINITION,
|
||||
"owner": "tg", # not allowed
|
||||
|
|
@ -64,7 +64,7 @@ def test_automation_create_rejects_empty_name() -> None:
|
|||
with pytest.raises(ValidationError):
|
||||
AutomationCreate.model_validate(
|
||||
{
|
||||
"search_space_id": 1,
|
||||
"workspace_id": 1,
|
||||
"name": "",
|
||||
"definition": _VALID_DEFINITION,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Lock creation-time model-policy enforcement in ``AutomationService``.
|
||||
|
||||
Creation (REST + manual builder) rejects search spaces whose models aren't
|
||||
Creation (REST + manual builder) rejects workspaces whose models aren't
|
||||
billable for automations with HTTP 422, mirroring the runtime backstop. These
|
||||
tests isolate the new ``_assert_models_billable`` / ``model_eligibility`` paths
|
||||
without touching the DB commit.
|
||||
|
|
@ -29,13 +29,13 @@ pytestmark = pytest.mark.unit
|
|||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, search_space: Any) -> None:
|
||||
self._search_space = search_space
|
||||
def __init__(self, workspace: Any) -> None:
|
||||
self._workspace = workspace
|
||||
self.added: list[Any] = []
|
||||
self.commits = 0
|
||||
|
||||
async def get(self, _model: Any, _pk: int) -> Any:
|
||||
return self._search_space
|
||||
return self._workspace
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
|
@ -44,9 +44,9 @@ class _FakeSession:
|
|||
self.commits += 1
|
||||
|
||||
|
||||
def _service(search_space: Any) -> AutomationService:
|
||||
def _service(workspace: Any) -> AutomationService:
|
||||
return AutomationService(
|
||||
session=_FakeSession(search_space),
|
||||
session=_FakeSession(workspace),
|
||||
auth=AuthContext.session(SimpleNamespace(id="u-1")),
|
||||
)
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ async def test_assert_models_billable_raises_422_on_violation(
|
|||
async def test_assert_models_billable_raises_404_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A missing search space is a 404, not a policy error."""
|
||||
"""A missing workspace is a 404, not a policy error."""
|
||||
monkeypatch.setattr(
|
||||
automation_mod, "assert_automation_models_billable", lambda _ss: None
|
||||
)
|
||||
|
|
@ -93,23 +93,23 @@ async def test_assert_models_billable_raises_404_when_missing(
|
|||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
async def test_assert_models_billable_returns_search_space_when_ok(
|
||||
async def test_assert_models_billable_returns_workspace_when_ok(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When the policy accepts, the loaded search space is returned for reuse."""
|
||||
"""When the policy accepts, the loaded workspace is returned for reuse."""
|
||||
monkeypatch.setattr(
|
||||
automation_mod, "assert_automation_models_billable", lambda _ss: None
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(chat_model_id=-1)
|
||||
service = _service(search_space)
|
||||
assert await service._assert_models_billable(1) is search_space
|
||||
workspace = SimpleNamespace(chat_model_id=-1)
|
||||
service = _service(workspace)
|
||||
assert await service._assert_models_billable(1) is workspace
|
||||
|
||||
|
||||
async def test_create_injects_captured_models_from_search_space(
|
||||
async def test_create_injects_captured_models_from_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""create() snapshots the search space's model prefs onto the definition."""
|
||||
"""create() snapshots the workspace's model prefs onto the definition."""
|
||||
monkeypatch.setattr(
|
||||
automation_mod, "assert_automation_models_billable", lambda _ss: None
|
||||
)
|
||||
|
|
@ -124,14 +124,14 @@ async def test_create_injects_captured_models_from_search_space(
|
|||
|
||||
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
|
||||
|
||||
search_space = SimpleNamespace(
|
||||
workspace = SimpleNamespace(
|
||||
chat_model_id=-1,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
service = _service(search_space)
|
||||
service = _service(workspace)
|
||||
payload = AutomationCreate(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
name="A",
|
||||
definition=_definition(),
|
||||
)
|
||||
|
|
@ -148,7 +148,7 @@ async def test_create_injects_captured_models_from_search_space(
|
|||
async def test_create_treats_unset_prefs_as_auto_zero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``None`` search-space prefs are captured as ``0`` (Auto) ids."""
|
||||
"""``None`` workspace prefs are captured as ``0`` (Auto) ids."""
|
||||
monkeypatch.setattr(
|
||||
automation_mod, "assert_automation_models_billable", lambda _ss: None
|
||||
)
|
||||
|
|
@ -163,13 +163,13 @@ async def test_create_treats_unset_prefs_as_auto_zero(
|
|||
|
||||
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
|
||||
|
||||
search_space = SimpleNamespace(
|
||||
workspace = SimpleNamespace(
|
||||
chat_model_id=None,
|
||||
image_gen_model_id=None,
|
||||
vision_model_id=None,
|
||||
)
|
||||
service = _service(search_space)
|
||||
payload = AutomationCreate(search_space_id=1, name="A", definition=_definition())
|
||||
service = _service(workspace)
|
||||
payload = AutomationCreate(workspace_id=1, name="A", definition=_definition())
|
||||
|
||||
automation = await service.create(payload)
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ async def test_create_honors_selected_models_when_provided(
|
|||
) -> None:
|
||||
"""When the payload carries ``definition.models`` they are validated + kept.
|
||||
|
||||
The search-space snapshot path is bypassed entirely (no
|
||||
The workspace snapshot path is bypassed entirely (no
|
||||
``assert_automation_models_billable`` call).
|
||||
"""
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ async def test_create_honors_selected_models_when_provided(
|
|||
|
||||
service = _service(SimpleNamespace(chat_model_id=-99))
|
||||
payload = AutomationCreate(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
name="A",
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
|
|
@ -257,7 +257,7 @@ async def test_create_rejects_unbillable_selected_models(
|
|||
|
||||
service = _service(SimpleNamespace(chat_model_id=-3))
|
||||
payload = AutomationCreate(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
name="A",
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
|
|
@ -284,7 +284,7 @@ async def test_update_preserves_captured_models(
|
|||
"vision_model_id": -1,
|
||||
}
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
definition={"name": "A", "plan": [], "models": captured},
|
||||
version=3,
|
||||
)
|
||||
|
|
@ -315,7 +315,7 @@ async def test_update_honors_changed_models_when_valid(
|
|||
) -> None:
|
||||
"""A definition edit with a *changed* models block validates + keeps it."""
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
definition={
|
||||
"name": "A",
|
||||
"plan": [],
|
||||
|
|
@ -376,7 +376,7 @@ async def test_update_rejects_changed_unbillable_models(
|
|||
) -> None:
|
||||
"""A *changed* non-billable models block is rejected with HTTP 422."""
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
definition={
|
||||
"name": "A",
|
||||
"plan": [],
|
||||
|
|
@ -438,7 +438,7 @@ async def test_update_keeps_unchanged_models_without_revalidation(
|
|||
"vision_model_id": -1,
|
||||
}
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
definition={"name": "A", "plan": [], "models": captured},
|
||||
version=3,
|
||||
)
|
||||
|
|
@ -477,7 +477,7 @@ async def test_model_eligibility_authorizes_and_returns_payload(
|
|||
authorized: dict[str, Any] = {}
|
||||
|
||||
async def _fake_check_permission(_session, _user, ss_id, permission, _msg):
|
||||
authorized["search_space_id"] = ss_id
|
||||
authorized["workspace_id"] = ss_id
|
||||
authorized["permission"] = permission
|
||||
|
||||
monkeypatch.setattr(automation_mod, "check_permission", _fake_check_permission)
|
||||
|
|
@ -488,8 +488,8 @@ async def test_model_eligibility_authorizes_and_returns_payload(
|
|||
)
|
||||
|
||||
service = _service(SimpleNamespace(chat_model_id=-2))
|
||||
result = await service.model_eligibility(search_space_id=5)
|
||||
result = await service.model_eligibility(workspace_id=5)
|
||||
|
||||
assert result == {"allowed": False, "violations": [{"kind": "image"}]}
|
||||
assert authorized["search_space_id"] == 5
|
||||
assert authorized["workspace_id"] == 5
|
||||
assert authorized["permission"] == "automations:read"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue