test: rename SearchSpace -> Workspace across tests + fixtures (Phase 2 Wave F)

Apply the same rename to surfsense_backend/tests: workspace_id fields/vars,
Workspace* classes/schemas, table name searchspaces -> workspaces in raw SQL,
and the API URL spellings -> /workspaces. Preserves the carve-out wire literals
tests assert (Celery task names, OTel key "search_space.id").
This commit is contained in:
CREDO23 2026-06-26 18:36:46 +02:00
parent 56826a63bc
commit ca9bd28934
124 changed files with 1269 additions and 1269 deletions

View file

@ -37,7 +37,7 @@ def sample_user_id() -> str:
@pytest.fixture @pytest.fixture
def sample_search_space_id() -> int: def sample_workspace_id() -> int:
return 1 return 1
@ -59,7 +59,7 @@ def make_connector_document():
"source_markdown": "## Heading\n\nSome content.", "source_markdown": "## Heading\n\nSome content.",
"unique_id": "test-id-001", "unique_id": "test-id-001",
"document_type": DocumentType.CLICKUP_CONNECTOR, "document_type": DocumentType.CLICKUP_CONNECTOR,
"search_space_id": 1, "workspace_id": 1,
"connector_id": 1, "connector_id": 1,
"created_by_id": "00000000-0000-0000-0000-000000000001", "created_by_id": "00000000-0000-0000-0000-000000000001",
} }

View file

@ -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.agents.chat.multi_agent_chat.shared.retrieval.models import SearchScope
from app.config import config 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 pytestmark = pytest.mark.integration
@ -35,7 +35,7 @@ def _axis(index: int) -> list[float]:
async def _add_document( async def _add_document(
db_session, db_session,
*, *,
search_space_id: int, workspace_id: int,
title: str = "Doc", title: str = "Doc",
document_type: DocumentType = DocumentType.FILE, document_type: DocumentType = DocumentType.FILE,
state: str = "ready", state: str = "ready",
@ -47,7 +47,7 @@ async def _add_document(
document_type=document_type, document_type=document_type,
content="\n".join(content for content, _, _ in chunks), content="\n".join(content for content, _, _ in chunks),
content_hash=uuid.uuid4().hex, content_hash=uuid.uuid4().hex,
search_space_id=search_space_id, workspace_id=workspace_id,
status={"state": state}, status={"state": state},
) )
db_session.add(document) db_session.add(document)
@ -65,17 +65,17 @@ async def _add_document(
return 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( document = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Asyncio Guide", title="Asyncio Guide",
chunks=[("The asyncio library enables concurrency.", 0, _axis(0))], chunks=[("The asyncio library enables concurrency.", 0, _axis(0))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(), scope=SearchScope(),
top_k=5, 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} 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( aligned = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Background Work", title="Background Work",
chunks=[("Parallel execution of background work.", 0, _axis(0))], chunks=[("Parallel execution of background work.", 0, _axis(0))],
) )
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Dessert", title="Dessert",
chunks=[("Recipes for chocolate cake.", 0, _axis(1))], chunks=[("Recipes for chocolate cake.", 0, _axis(1))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asynchronous coroutines", query="asynchronous coroutines",
scope=SearchScope(), scope=SearchScope(),
top_k=5, 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 assert results[0].document_id == aligned.id
async def test_results_stay_within_the_search_space(db_session, db_search_space): async def test_results_stay_within_the_workspace(db_session, db_workspace):
other_space = SearchSpace(name="Other Space", user_id=db_search_space.user_id) other_space = Workspace(name="Other Space", user_id=db_workspace.user_id)
db_session.add(other_space) db_session.add(other_space)
await db_session.flush() await db_session.flush()
mine = await _add_document( mine = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
chunks=[("Shared keyword asyncio here.", 0, _axis(0))], chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
) )
foreign = await _add_document( foreign = await _add_document(
db_session, db_session,
search_space_id=other_space.id, workspace_id=other_space.id,
chunks=[("Shared keyword asyncio here.", 0, _axis(0))], chunks=[("Shared keyword asyncio here.", 0, _axis(0))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(), scope=SearchScope(),
top_k=5, 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 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( pinned = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
chunks=[("asyncio appears in the pinned doc.", 0, _axis(0))], chunks=[("asyncio appears in the pinned doc.", 0, _axis(0))],
) )
await _add_document( await _add_document(
db_session, 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))], chunks=[("asyncio appears in the other doc too.", 0, _axis(0))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(document_ids=(pinned.id,)), scope=SearchScope(document_ids=(pinned.id,)),
top_k=5, 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} 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( ready = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
chunks=[("asyncio in a ready document.", 0, _axis(0))], chunks=[("asyncio in a ready document.", 0, _axis(0))],
) )
deleting = await _add_document( deleting = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
state="deleting", state="deleting",
chunks=[("asyncio in a deleting document.", 0, _axis(0))], chunks=[("asyncio in a deleting document.", 0, _axis(0))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(), scope=SearchScope(),
top_k=5, 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 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 # Insert out of order, and give the later-position chunk the stronger
# semantic score, so reading order differs from both insertion and score. # semantic score, so reading order differs from both insertion and score.
document = await _add_document( document = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
chunks=[ chunks=[
("asyncio paragraph two.", 1, _axis(0)), ("asyncio paragraph two.", 1, _axis(0)),
("asyncio paragraph one.", 0, _axis(50)), ("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( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(), scope=SearchScope(),
top_k=5, 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] 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): for index in range(3):
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title=f"Doc {index}", title=f"Doc {index}",
chunks=[(f"asyncio mentioned in doc {index}.", 0, _axis(index))], chunks=[(f"asyncio mentioned in doc {index}.", 0, _axis(index))],
) )
results = await search_chunks( results = await search_chunks(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
query="asyncio", query="asyncio",
scope=SearchScope(), scope=SearchScope(),
top_k=2, top_k=2,

View file

@ -48,7 +48,7 @@ def _axis(index: int) -> list[float]:
async def _add_document( async def _add_document(
db_session, db_session,
*, *,
search_space_id: int, workspace_id: int,
title: str, title: str,
text: str, text: str,
folder_id: int | None = None, folder_id: int | None = None,
@ -58,7 +58,7 @@ async def _add_document(
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content=text, content=text,
content_hash=uuid.uuid4().hex, content_hash=uuid.uuid4().hex,
search_space_id=search_space_id, workspace_id=workspace_id,
folder_id=folder_id, folder_id=folder_id,
status={"state": "ready"}, status={"state": "ready"},
) )
@ -71,8 +71,8 @@ async def _add_document(
return document return document
async def _add_folder(db_session, *, search_space_id: int, name: str = "Folder"): async def _add_folder(db_session, *, workspace_id: int, name: str = "Folder"):
folder = Folder(name=name, position="0", search_space_id=search_space_id) folder = Folder(name=name, position="0", workspace_id=workspace_id)
db_session.add(folder) db_session.add(folder)
await db_session.flush() await db_session.flush()
return folder return folder
@ -109,15 +109,15 @@ def _mentions(*, document_ids=(), folder_ids=()):
async def test_tool_returns_retrieved_context_with_numbered_passages( 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( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Asyncio Guide", title="Asyncio Guide",
text="The asyncio library enables concurrency.", 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") 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( 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( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Asyncio Guide", title="Asyncio Guide",
text="The asyncio library enables concurrency.", 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") 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( 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( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Asyncio Guide", title="Asyncio Guide",
text="The asyncio library enables concurrency.", 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") first = await _invoke(tool, "asyncio")
carried = first.update["citation_registry"] 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( 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") 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( 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, " ") 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( 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( pinned = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Pinned", title="Pinned",
text="asyncio appears in the pinned doc.", text="asyncio appears in the pinned doc.",
) )
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Other", title="Other",
text="asyncio appears in the other doc.", 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])) 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( 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( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Inside", title="Inside",
text="asyncio appears inside the folder.", text="asyncio appears inside the folder.",
folder_id=folder.id, folder_id=folder.id,
) )
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Outside", title="Outside",
text="asyncio appears outside the folder.", 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])) 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( 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). """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( pinned = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Pinned", title="Pinned",
text="asyncio appears in the pinned doc.", text="asyncio appears in the pinned doc.",
) )
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Other", title="Other",
text="asyncio appears in the other doc.", 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( result = await _invoke(
tool, tool,
@ -275,24 +275,24 @@ async def test_document_mention_via_state_confines_search(
async def test_folder_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 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( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Inside", title="Inside",
text="asyncio appears inside the folder.", text="asyncio appears inside the folder.",
folder_id=folder.id, folder_id=folder.id,
) )
await _add_document( await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Outside", title="Outside",
text="asyncio appears outside the folder.", 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( result = await _invoke(
tool, tool,
@ -307,22 +307,22 @@ async def test_folder_mention_via_state_confines_search(
async def test_state_mentions_take_precedence_over_context( 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).""" """When both carry pins, state wins (the forwarded subagent pin is authoritative)."""
state_doc = await _add_document( state_doc = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="StatePinned", title="StatePinned",
text="asyncio appears in the state-pinned doc.", text="asyncio appears in the state-pinned doc.",
) )
context_doc = await _add_document( context_doc = await _add_document(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="ContextPinned", title="ContextPinned",
text="asyncio appears in the context-pinned doc.", 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( result = await _invoke(
tool, tool,

View file

@ -35,18 +35,18 @@ def _last_ai_text(messages: list) -> str | None:
@pytest.mark.asyncio @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.""" """A freshly assembled agent streams a scripted final-text turn to completion."""
harness = build_scripted_harness(turns=[ScriptedTurn(text="done")]) harness = build_scripted_harness(turns=[ScriptedTurn(text="done")])
agent = await create_multi_agent_chat_deep_agent( agent = await create_multi_agent_chat_deep_agent(
llm=harness.model, llm=harness.model,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
db_session=db_session, db_session=db_session,
connector_service=ConnectorService(db_session), connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(), checkpointer=InMemorySaver(),
user_id=str(db_user.id), user_id=str(db_user.id),
thread_id=db_search_space.id, thread_id=db_workspace.id,
agent_config=None, 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 @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.""" """The compiled graph routes a model tool call to its tool and resumes."""
harness = build_scripted_harness( harness = build_scripted_harness(
turns=[ 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( agent = await create_multi_agent_chat_deep_agent(
llm=harness.model, llm=harness.model,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
db_session=db_session, db_session=db_session,
connector_service=ConnectorService(db_session), connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(), checkpointer=InMemorySaver(),
user_id=str(db_user.id), user_id=str(db_user.id),
thread_id=db_search_space.id, thread_id=db_workspace.id,
agent_config=None, agent_config=None,
additional_tools=harness.tools, 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 @pytest.mark.asyncio
async def test_agent_checkpoint_round_trips_across_turns( 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. """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(): async def _build():
return await create_multi_agent_chat_deep_agent( return await create_multi_agent_chat_deep_agent(
llm=harness.model, llm=harness.model,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
db_session=db_session, db_session=db_session,
connector_service=ConnectorService(db_session), connector_service=ConnectorService(db_session),
checkpointer=checkpointer, checkpointer=checkpointer,
user_id=str(db_user.id), user_id=str(db_user.id),
thread_id=db_search_space.id, thread_id=db_workspace.id,
agent_config=None, agent_config=None,
) )

View file

@ -40,16 +40,16 @@ _SEARCH_SPACE_ID = 1
def _build_cloud_fs_mw(): def _build_cloud_fs_mw():
"""Build the production filesystem middleware in cloud mode. """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 ``KBPostgresBackend``, exactly as production does. Staging operations never
touch the DB, so a dummy id is sufficient for these tests. touch the DB, so a dummy id is sufficient for these tests.
""" """
selection = FilesystemSelection(mode=FilesystemMode.CLOUD) 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( return build_filesystem_mw(
backend_resolver=resolver, backend_resolver=resolver,
filesystem_mode=FilesystemMode.CLOUD, filesystem_mode=FilesystemMode.CLOUD,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id="00000000-0000-0000-0000-000000000001", user_id="00000000-0000-0000-0000-000000000001",
thread_id=_SEARCH_SPACE_ID, thread_id=_SEARCH_SPACE_ID,
read_only=False, read_only=False,

View file

@ -51,7 +51,7 @@ def _build_desktop_fs_mw(root: Path):
return build_filesystem_mw( return build_filesystem_mw(
backend_resolver=resolver, backend_resolver=resolver,
filesystem_mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, filesystem_mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
search_space_id=1, workspace_id=1,
user_id="00000000-0000-0000-0000-000000000001", user_id="00000000-0000-0000-0000-000000000001",
thread_id=1, thread_id=1,
read_only=False, read_only=False,

View file

@ -46,7 +46,7 @@ from app.db import (
NewChatMessage, NewChatMessage,
NewChatMessageRole, NewChatMessageRole,
NewChatThread, NewChatThread,
SearchSpace, Workspace,
TokenUsage, TokenUsage,
User, User,
) )
@ -69,11 +69,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_thread( async def db_thread(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread: ) -> NewChatThread:
thread = NewChatThread( thread = NewChatThread(
title="Test Chat", title="Test Chat",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=db_user.id, created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE, visibility=ChatVisibility.PRIVATE,
) )
@ -109,7 +109,7 @@ def bypass_permission_checks(monkeypatch):
"""Replace RBAC + thread access checks with no-ops. """Replace RBAC + thread access checks with no-ops.
The append_message route under test calls ``check_permission`` and 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 that the existing integration fixtures don't create. The contract
we want to verify here is the ``IntegrityError`` -> recovery branch, we want to verify here is the ``IntegrityError`` -> recovery branch,
not the RBAC plumbing so stub them. not the RBAC plumbing so stub them.
@ -208,7 +208,7 @@ class TestToolHeavyTurnFinalize:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
"""End-to-end seam: builder snapshot -> finalize -> DB row. """End-to-end seam: builder snapshot -> finalize -> DB row.
@ -221,7 +221,7 @@ class TestToolHeavyTurnFinalize:
""" """
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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" turn_id = f"{thread_id}:tool_heavy"
msg_id = await persist_assistant_shell( msg_id = await persist_assistant_shell(
@ -258,7 +258,7 @@ class TestToolHeavyTurnFinalize:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=snapshot, content=snapshot,
@ -300,7 +300,7 @@ class TestToolHeavyTurnFinalize:
assert usage.total_tokens == 280 assert usage.total_tokens == 280
assert usage.cost_micros == 22222 assert usage.cost_micros == 22222
assert usage.thread_id == thread_id 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_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
bypass_permission_checks, bypass_permission_checks,
): ):
@ -337,7 +337,7 @@ class TestAppendMessageRecoveryAfterFinalize:
""" """
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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" turn_id = f"{thread_id}:fe_late_append"
# Step 1: server stream completes. Server-built rich content is # Step 1: server stream completes. Server-built rich content is
@ -353,7 +353,7 @@ class TestAppendMessageRecoveryAfterFinalize:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=server_content, content=server_content,
@ -439,7 +439,7 @@ class TestAppendMessageRecoveryAfterFinalize:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
bypass_permission_checks, bypass_permission_checks,
): ):
@ -456,7 +456,7 @@ class TestAppendMessageRecoveryAfterFinalize:
""" """
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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" turn_id = f"{thread_id}:fe_first"
# Step 1: legacy FE appendMessage lands first. No prior shell # Step 1: legacy FE appendMessage lands first. No prior shell
@ -490,7 +490,7 @@ class TestAppendMessageRecoveryAfterFinalize:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=adopted_id, message_id=adopted_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=server_content, content=server_content,

View file

@ -48,7 +48,7 @@ from app.db import (
NewChatMessage, NewChatMessage,
NewChatMessageRole, NewChatMessageRole,
NewChatThread, NewChatThread,
SearchSpace, Workspace,
User, User,
) )
from app.services.new_streaming_service import VercelStreamingService from app.services.new_streaming_service import VercelStreamingService
@ -68,11 +68,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_thread( async def db_thread(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread: ) -> NewChatThread:
thread = NewChatThread( thread = NewChatThread(
title="Test Chat", title="Test Chat",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=db_user.id, created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE, visibility=ChatVisibility.PRIVATE,
) )

View file

@ -38,7 +38,7 @@ from app.db import (
NewChatMessage, NewChatMessage,
NewChatMessageRole, NewChatMessageRole,
NewChatThread, NewChatThread,
SearchSpace, Workspace,
TokenUsage, TokenUsage,
User, User,
) )
@ -60,11 +60,11 @@ pytestmark = pytest.mark.integration
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_thread( async def db_thread(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread: ) -> NewChatThread:
thread = NewChatThread( thread = NewChatThread(
title="Test Chat", title="Test Chat",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=db_user.id, created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE, visibility=ChatVisibility.PRIVATE,
) )
@ -537,13 +537,13 @@ class TestFinalizeAssistantTurn:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
thread_id = db_thread.id thread_id = db_thread.id
user_id_uuid = db_user.id user_id_uuid = db_user.id
user_id_str = str(user_id_uuid) 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" turn_id = f"{thread_id}:4000"
msg_id = await persist_assistant_shell( msg_id = await persist_assistant_shell(
@ -568,7 +568,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=rich_content, content=rich_content,
@ -597,19 +597,19 @@ class TestFinalizeAssistantTurn:
assert usage.total_tokens == 150 assert usage.total_tokens == 150
assert usage.cost_micros == 12345 assert usage.cost_micros == 12345
assert usage.thread_id == thread_id 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( async def test_empty_content_writes_status_marker(
self, self,
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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" turn_id = f"{thread_id}:5000"
msg_id = await persist_assistant_shell( msg_id = await persist_assistant_shell(
@ -624,7 +624,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=[], content=[],
@ -640,12 +640,12 @@ class TestFinalizeAssistantTurn:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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" turn_id = f"{thread_id}:6000"
msg_id = await persist_assistant_shell( msg_id = await persist_assistant_shell(
@ -659,7 +659,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=[{"type": "text", "text": "first finalize"}], content=[{"type": "text", "text": "first finalize"}],
@ -681,7 +681,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=[{"type": "text", "text": "second finalize"}], content=[{"type": "text", "text": "second finalize"}],
@ -708,7 +708,7 @@ class TestFinalizeAssistantTurn:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
"""Cross-writer race: ``append_message`` arrives after ``finalize_assistant_turn``. """Cross-writer race: ``append_message`` arrives after ``finalize_assistant_turn``.
@ -722,7 +722,7 @@ class TestFinalizeAssistantTurn:
thread_id = db_thread.id thread_id = db_thread.id
user_uuid = db_user.id user_uuid = db_user.id
user_id_str = str(user_uuid) user_id_str = str(user_uuid)
search_space_id = db_search_space.id workspace_id = db_workspace.id
turn_id = f"{thread_id}:7000" turn_id = f"{thread_id}:7000"
msg_id = await persist_assistant_shell( msg_id = await persist_assistant_shell(
@ -735,7 +735,7 @@ class TestFinalizeAssistantTurn:
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=msg_id, message_id=msg_id,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id=turn_id, turn_id=turn_id,
content=[{"type": "text", "text": "from server"}], content=[{"type": "text", "text": "from server"}],
@ -758,7 +758,7 @@ class TestFinalizeAssistantTurn:
call_details=None, call_details=None,
thread_id=thread_id, thread_id=thread_id,
message_id=msg_id, message_id=msg_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_uuid, user_id=user_uuid,
) )
.on_conflict_do_nothing( .on_conflict_do_nothing(
@ -783,19 +783,19 @@ class TestFinalizeAssistantTurn:
db_session, db_session,
db_user, db_user,
db_thread, db_thread,
db_search_space, db_workspace,
patched_shielded_session, patched_shielded_session,
): ):
thread_id = db_thread.id thread_id = db_thread.id
user_id_str = str(db_user.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, # message_id that doesn't exist — finalize must log+return,
# never raise (called from shielded finally). # never raise (called from shielded finally).
await finalize_assistant_turn( await finalize_assistant_turn(
message_id=999_999_999, message_id=999_999_999,
chat_id=thread_id, chat_id=thread_id,
search_space_id=search_space_id, workspace_id=workspace_id,
user_id=user_id_str, user_id=user_id_str,
turn_id="anything", turn_id="anything",
content=[{"type": "text", "text": "x"}], content=[{"type": "text", "text": "x"}],

View file

@ -2,7 +2,7 @@
These tests exercise the route handlers directly with real DB-backed These tests exercise the route handlers directly with real DB-backed
users, memberships, and permissions. The important contract is that a 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. updates until the creator explicitly makes it private again.
""" """
@ -19,9 +19,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.db import ( from app.db import (
ChatVisibility, ChatVisibility,
SearchSpace, Workspace,
SearchSpaceMembership, WorkspaceMembership,
SearchSpaceRole, WorkspaceRole,
User, User,
) )
from app.routes import new_chat_routes from app.routes import new_chat_routes
@ -39,7 +39,7 @@ def _auth(user: User) -> AuthContext:
@pytest_asyncio.fixture @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( member = User(
id=uuid.uuid4(), id=uuid.uuid4(),
email="member@surfsense.net", email="member@surfsense.net",
@ -54,9 +54,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
role = ( role = (
( (
await db_session.execute( await db_session.execute(
select(SearchSpaceRole).where( select(WorkspaceRole).where(
SearchSpaceRole.search_space_id == db_search_space.id, WorkspaceRole.workspace_id == db_workspace.id,
SearchSpaceRole.name == "Editor", WorkspaceRole.name == "Editor",
) )
) )
) )
@ -64,9 +64,9 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
.one() .one()
) )
db_session.add( db_session.add(
SearchSpaceMembership( WorkspaceMembership(
user_id=member.id, user_id=member.id,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
role_id=role.id, role_id=role.id,
is_owner=False, is_owner=False,
) )
@ -78,7 +78,7 @@ async def db_member(db_session: AsyncSession, db_search_space: SearchSpace) -> U
async def _create_thread( async def _create_thread(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
*, *,
title: str = "Visibility Invariant Chat", title: str = "Visibility Invariant Chat",
): ):
@ -86,7 +86,7 @@ async def _create_thread(
NewChatThreadCreate( NewChatThreadCreate(
title=title, title=title,
archived=False, archived=False,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
visibility=ChatVisibility.PRIVATE, visibility=ChatVisibility.PRIVATE,
), ),
session=db_session, session=db_session,
@ -102,21 +102,21 @@ def _search_thread_ids(response) -> set[int]:
return {thread.id for thread in response} 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_session: AsyncSession,
db_user: User, db_user: User,
db_member: 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( member_threads = await new_chat_routes.list_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
session=db_session, session=db_session,
auth=_auth(db_member), auth=_auth(db_member),
) )
member_search = await new_chat_routes.search_threads( member_search = await new_chat_routes.search_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Visibility", title="Visibility",
session=db_session, session=db_session,
auth=_auth(db_member), 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_session: AsyncSession,
db_user: User, db_user: User,
db_member: 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( updated = await new_chat_routes.update_thread_visibility(
thread_id=thread.id, 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( member_threads = await new_chat_routes.list_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
session=db_session, session=db_session,
auth=_auth(db_member), auth=_auth(db_member),
) )
member_search = await new_chat_routes.search_threads( member_search = await new_chat_routes.search_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Visibility", title="Visibility",
session=db_session, session=db_session,
auth=_auth(db_member), 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( async def test_rename_and_archive_do_not_reset_shared_visibility(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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( await new_chat_routes.update_thread_visibility(
thread_id=thread.id, thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate( visibility_update=NewChatThreadVisibilityUpdate(
@ -211,9 +211,9 @@ async def test_non_creator_cannot_change_shared_thread_back_to_private(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_member: 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( await new_chat_routes.update_thread_visibility(
thread_id=thread.id, thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate( visibility_update=NewChatThreadVisibilityUpdate(
@ -240,9 +240,9 @@ async def test_creator_can_make_shared_thread_private_again(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_member: 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( await new_chat_routes.update_thread_visibility(
thread_id=thread.id, thread_id=thread.id,
visibility_update=NewChatThreadVisibilityUpdate( visibility_update=NewChatThreadVisibilityUpdate(
@ -261,12 +261,12 @@ async def test_creator_can_make_shared_thread_private_again(
auth=_auth(db_user), auth=_auth(db_user),
) )
member_threads = await new_chat_routes.list_threads( member_threads = await new_chat_routes.list_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
session=db_session, session=db_session,
auth=_auth(db_member), auth=_auth(db_member),
) )
member_search = await new_chat_routes.search_threads( member_search = await new_chat_routes.search_threads(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Visibility", title="Visibility",
session=db_session, session=db_session,
auth=_auth(db_member), auth=_auth(db_member),

View file

@ -65,7 +65,7 @@ async def client(
async def drive_connector( async def drive_connector(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space, db_workspace,
) -> SearchSourceConnector: ) -> SearchSourceConnector:
connector = SearchSourceConnector( connector = SearchSourceConnector(
name="Google Drive (Composio) - e2e-fake@surfsense.example", name="Google Drive (Composio) - e2e-fake@surfsense.example",
@ -77,7 +77,7 @@ async def drive_connector(
"toolkit_name": "Google Drive", "toolkit_name": "Google Drive",
"is_indexable": True, "is_indexable": True,
}, },
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=db_user.id, user_id=db_user.id,
) )
db_session.add(connector) db_session.add(connector)

View file

@ -9,7 +9,7 @@ from app.config import config
from app.db import ( from app.db import (
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
SearchSpace, Workspace,
User, User,
) )
from app.utils.oauth_security import OAuthStateManager from app.utils.oauth_security import OAuthStateManager
@ -29,12 +29,12 @@ async def _drive_connectors(
session: AsyncSession, session: AsyncSession,
*, *,
user_id: UUID, user_id: UUID,
search_space_id: int, workspace_id: int,
) -> list[SearchSourceConnector]: ) -> list[SearchSourceConnector]:
result = await session.execute( result = await session.execute(
select(SearchSourceConnector).where( select(SearchSourceConnector).where(
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.connector_type SearchSourceConnector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
) )
@ -46,9 +46,9 @@ async def test_callback_with_error_param_redirects_to_denied_page(
client: httpx.AsyncClient, client: httpx.AsyncClient,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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( response = await client.get(
f"/api/v1/auth/composio/connector/callback?state={state}&error=access_denied" f"/api/v1/auth/composio/connector/callback?state={state}&error=access_denied"
@ -57,14 +57,14 @@ async def test_callback_with_error_param_redirects_to_denied_page(
assert response.status_code in {302, 303, 307} assert response.status_code in {302, 303, 307}
location = response.headers["location"] location = response.headers["location"]
assert ( assert (
f"/dashboard/{db_search_space.id}/connectors/callback?" f"/dashboard/{db_workspace.id}/connectors/callback?"
"error=composio_oauth_denied" "error=composio_oauth_denied"
) in location ) in location
connectors = await _drive_connectors( connectors = await _drive_connectors(
db_session, db_session,
user_id=db_user.id, user_id=db_user.id,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert connectors == [] assert connectors == []
@ -73,9 +73,9 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
client: httpx.AsyncClient, client: httpx.AsyncClient,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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( first_response = await client.get(
"/api/v1/auth/composio/connector/callback" "/api/v1/auth/composio/connector/callback"
@ -86,7 +86,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
first_connectors = await _drive_connectors( first_connectors = await _drive_connectors(
db_session, db_session,
user_id=db_user.id, user_id=db_user.id,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert len(first_connectors) == 1 assert len(first_connectors) == 1
first_connector = first_connectors[0] first_connector = first_connectors[0]
@ -94,7 +94,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
"fake-acct-googledrive-first" "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( second_response = await client.get(
"/api/v1/auth/composio/connector/callback" "/api/v1/auth/composio/connector/callback"
f"?state={second_state}&connectedAccountId=fake-acct-googledrive-second" f"?state={second_state}&connectedAccountId=fake-acct-googledrive-second"
@ -104,7 +104,7 @@ async def test_second_oauth_for_same_toolkit_takes_reconnection_branch(
second_connectors = await _drive_connectors( second_connectors = await _drive_connectors(
db_session, db_session,
user_id=db_user.id, user_id=db_user.id,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert len(second_connectors) == 1 assert len(second_connectors) == 1
assert second_connectors[0].id == first_connector.id assert second_connectors[0].id == first_connector.id

View file

@ -21,13 +21,13 @@ Base = app_db.Base
DocumentType = app_db.DocumentType DocumentType = app_db.DocumentType
SearchSourceConnector = app_db.SearchSourceConnector SearchSourceConnector = app_db.SearchSourceConnector
SearchSourceConnectorType = app_db.SearchSourceConnectorType SearchSourceConnectorType = app_db.SearchSourceConnectorType
SearchSpace = app_db.SearchSpace Workspace = app_db.Workspace
User = app_db.User User = app_db.User
ConnectorDocument = importlib.import_module( ConnectorDocument = importlib.import_module(
"app.indexing_pipeline.connector_document" "app.indexing_pipeline.connector_document"
).ConnectorDocument ).ConnectorDocument
create_default_roles_and_membership = importlib.import_module( create_default_roles_and_membership = importlib.import_module(
"app.routes.search_spaces_routes" "app.routes.workspaces_routes"
).create_default_roles_and_membership ).create_default_roles_and_membership
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL 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 @pytest_asyncio.fixture
async def db_connector( async def db_connector(
db_session: AsyncSession, db_user: User, db_search_space: "SearchSpace" db_session: AsyncSession, db_user: User, db_workspace: "Workspace"
) -> SearchSourceConnector: ) -> SearchSourceConnector:
connector = SearchSourceConnector( connector = SearchSourceConnector(
name="Test Connector", name="Test Connector",
connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR, connector_type=SearchSourceConnectorType.CLICKUP_CONNECTOR,
config={}, config={},
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=db_user.id, user_id=db_user.id,
) )
db_session.add(connector) db_session.add(connector)
@ -110,14 +110,14 @@ async def db_connector(
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpace: async def db_workspace(db_session: AsyncSession, db_user: User) -> Workspace:
space = SearchSpace( space = Workspace(
name="Test Space", name="Test Space",
user_id=db_user.id, user_id=db_user.id,
) )
db_session.add(space) db_session.add(space)
await db_session.flush() 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 create_default_roles_and_membership(db_session, space.id, db_user.id)
await db_session.flush() await db_session.flush()
return space return space
@ -180,7 +180,7 @@ def make_connector_document(db_connector, db_user):
"source_markdown": "## Heading\n\nSome content.", "source_markdown": "## Heading\n\nSome content.",
"unique_id": "test-id-001", "unique_id": "test-id-001",
"document_type": DocumentType.CLICKUP_CONNECTOR, "document_type": DocumentType.CLICKUP_CONNECTOR,
"search_space_id": db_connector.search_space_id, "workspace_id": db_connector.workspace_id,
"connector_id": db_connector.id, "connector_id": db_connector.id,
"created_by_id": str(db_user.id), "created_by_id": str(db_user.id),
} }

View file

@ -34,7 +34,7 @@ from tests.utils.helpers import (
auth_headers, auth_headers,
delete_document, delete_document,
get_auth_token, get_auth_token,
get_search_space_id, get_workspace_id,
) )
limiter.enabled = False limiter.enabled = False
@ -66,7 +66,7 @@ class InlineTaskDispatcher:
document_id: int, document_id: int,
temp_path: str, temp_path: str,
filename: str, filename: str,
search_space_id: int, workspace_id: int,
user_id: str, user_id: str,
use_vision_llm: bool = False, use_vision_llm: bool = False,
processing_mode: str = "basic", processing_mode: str = "basic",
@ -80,7 +80,7 @@ class InlineTaskDispatcher:
document_id, document_id,
temp_path, temp_path,
filename, filename,
search_space_id, workspace_id,
user_id, user_id,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode, 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") @pytest.fixture(scope="session")
async def search_space_id(auth_token: str) -> int: async def workspace_id(auth_token: str) -> int:
"""Discover the first search space belonging to the test user.""" """Discover the first workspace belonging to the test user."""
async with httpx.AsyncClient( async with httpx.AsyncClient(
transport=ASGITransport(app=app), base_url="http://test", timeout=30.0 transport=ASGITransport(app=app), base_url="http://test", timeout=30.0
) as c: ) as c:
return await get_search_space_id(c, auth_token) return await get_workspace_id(c, auth_token)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -155,19 +155,19 @@ def cleanup_doc_ids() -> list[int]:
@pytest.fixture(scope="session", autouse=True) @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.""" """Delete stale documents from previous runs before the session starts."""
conn = await asyncpg.connect(_ASYNCPG_URL) conn = await asyncpg.connect(_ASYNCPG_URL)
try: try:
result = await conn.execute( result = await conn.execute(
"DELETE FROM documents WHERE workspace_id = $1", "DELETE FROM documents WHERE workspace_id = $1",
search_space_id, workspace_id,
) )
deleted = int(result.split()[-1]) deleted = int(result.split()[-1])
if deleted: if deleted:
print( print(
f"\n[purge] Deleted {deleted} stale document(s) " f"\n[purge] Deleted {deleted} stale document(s) "
f"from search space {search_space_id}" f"from workspace {workspace_id}"
) )
finally: finally:
await conn.close() await conn.close()

View file

@ -37,11 +37,11 @@ class TestTxtFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_file( 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 assert resp.status_code == 200
@ -54,18 +54,18 @@ class TestTxtFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready" assert statuses[did]["status"]["state"] == "ready"
@ -78,18 +78,18 @@ class TestPdfFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready" assert statuses[did]["status"]["state"] == "ready"
@ -107,14 +107,14 @@ class TestMultiFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_multiple_files( resp = await upload_multiple_files(
client, client,
headers, headers,
["sample.txt", "sample.md"], ["sample.txt", "sample.md"],
search_space_id=search_space_id, workspace_id=workspace_id,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@ -139,22 +139,22 @@ class TestDuplicateFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp1 = await upload_file( 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 assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"] first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids) cleanup_doc_ids.extend(first_ids)
await poll_document_status( 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( 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 assert resp2.status_code == 200
@ -179,18 +179,18 @@ class TestDuplicateContentDetection:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
tmp_path: Path, tmp_path: Path,
): ):
resp1 = await upload_file( 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 assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"] first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids) cleanup_doc_ids.extend(first_ids)
await poll_document_status( 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" src = FIXTURES_DIR / "sample.txt"
@ -202,7 +202,7 @@ class TestDuplicateContentDetection:
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
headers=headers, headers=headers,
files={"files": ("renamed_sample.txt", f)}, 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 assert resp2.status_code == 200
second_ids = resp2.json()["document_ids"] second_ids = resp2.json()["document_ids"]
@ -212,7 +212,7 @@ class TestDuplicateContentDetection:
) )
statuses = await poll_document_status( 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: for did in second_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
@ -231,11 +231,11 @@ class TestEmptyFileUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_file( 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 assert resp.status_code == 200
@ -244,7 +244,7 @@ class TestEmptyFileUpload:
assert doc_ids, "Expected at least one document id for empty PDF upload" assert doc_ids, "Expected at least one document id for empty PDF upload"
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
@ -264,14 +264,14 @@ class TestUnauthenticatedUpload:
async def test_upload_without_auth_returns_401( async def test_upload_without_auth_returns_401(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
search_space_id: int, workspace_id: int,
): ):
file_path = FIXTURES_DIR / "sample.txt" file_path = FIXTURES_DIR / "sample.txt"
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
resp = await client.post( resp = await client.post(
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
files={"files": ("sample.txt", f)}, files={"files": ("sample.txt", f)},
data={"search_space_id": str(search_space_id)}, data={"workspace_id": str(workspace_id)},
) )
assert resp.status_code == 401 assert resp.status_code == 401
@ -288,12 +288,12 @@ class TestNoFilesUpload:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
): ):
resp = await client.post( resp = await client.post(
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
headers=headers, headers=headers,
data={"search_space_id": str(search_space_id)}, data={"workspace_id": str(workspace_id)},
) )
assert resp.status_code in {400, 422} assert resp.status_code in {400, 422}
@ -310,24 +310,24 @@ class TestDocumentSearchability:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
await poll_document_status( await poll_document_status(
client, headers, doc_ids, search_space_id=search_space_id client, headers, doc_ids, workspace_id=workspace_id
) )
search_resp = await client.get( search_resp = await client.get(
"/api/v1/documents/search", "/api/v1/documents/search",
headers=headers, headers=headers,
params={"title": "sample", "search_space_id": search_space_id}, params={"title": "sample", "workspace_id": workspace_id},
) )
assert search_resp.status_code == 200 assert search_resp.status_code == 200

View file

@ -57,7 +57,7 @@ class TestBalanceDecrementsOnSuccess:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
@ -65,14 +65,14 @@ class TestBalanceDecrementsOnSuccess:
before = await _get_balance(client, headers) before = await _get_balance(client, headers)
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "ready" assert statuses[did]["status"]["state"] == "ready"
@ -94,21 +94,21 @@ class TestUploadRejectedWhenCreditExhausted:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
await credits.set(balance_micros=0) await credits.set(balance_micros=0)
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
@ -121,21 +121,21 @@ class TestUploadRejectedWhenCreditExhausted:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
await credits.set(balance_micros=0) await credits.set(balance_micros=0)
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
await poll_document_status( 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) balance = await _get_balance(client, headers)
@ -157,28 +157,28 @@ class TestInsufficientCreditsNotification:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
await credits.set(balance_micros=0) await credits.set(balance_micros=0)
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
await poll_document_status( 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( notifications = await get_notifications(
client, client,
headers, headers,
type_filter="insufficient_credits", type_filter="insufficient_credits",
search_space_id=search_space_id, workspace_id=workspace_id,
) )
assert len(notifications) >= 1, ( assert len(notifications) >= 1, (
"Expected at least one insufficient_credits notification" "Expected at least one insufficient_credits notification"
@ -206,28 +206,28 @@ class TestDocumentProcessingNotification:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
await credits.set(balance_micros=credits.pages(1000)) await credits.set(balance_micros=credits.pages(1000))
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
cleanup_doc_ids.extend(doc_ids) cleanup_doc_ids.extend(doc_ids)
await poll_document_status( await poll_document_status(
client, headers, doc_ids, search_space_id=search_space_id client, headers, doc_ids, workspace_id=workspace_id
) )
notifications = await get_notifications( notifications = await get_notifications(
client, client,
headers, headers,
type_filter="document_processing", type_filter="document_processing",
search_space_id=search_space_id, workspace_id=workspace_id,
) )
completed = [ completed = [
n n
@ -252,7 +252,7 @@ class TestBalanceUnchangedOnProcessingFailure:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
@ -260,7 +260,7 @@ class TestBalanceUnchangedOnProcessingFailure:
await credits.set(balance_micros=starting) await credits.set(balance_micros=starting)
resp = await upload_file( 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 assert resp.status_code == 200
doc_ids = resp.json()["document_ids"] doc_ids = resp.json()["document_ids"]
@ -268,7 +268,7 @@ class TestBalanceUnchangedOnProcessingFailure:
if doc_ids: if doc_ids:
statuses = await poll_document_status( 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: for did in doc_ids:
assert statuses[did]["status"]["state"] == "failed" assert statuses[did]["status"]["state"] == "failed"
@ -292,7 +292,7 @@ class TestSecondUploadExceedsCredit:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
credits, credits,
): ):
@ -301,14 +301,14 @@ class TestSecondUploadExceedsCredit:
await credits.set(balance_micros=credits.pages(1)) await credits.set(balance_micros=credits.pages(1))
resp1 = await upload_file( 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 assert resp1.status_code == 200
first_ids = resp1.json()["document_ids"] first_ids = resp1.json()["document_ids"]
cleanup_doc_ids.extend(first_ids) cleanup_doc_ids.extend(first_ids)
statuses1 = await poll_document_status( 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: for did in first_ids:
assert statuses1[did]["status"]["state"] == "ready" assert statuses1[did]["status"]["state"] == "ready"
@ -317,7 +317,7 @@ class TestSecondUploadExceedsCredit:
client, client,
headers, headers,
"sample.pdf", "sample.pdf",
search_space_id=search_space_id, workspace_id=workspace_id,
filename_override="sample_copy.pdf", filename_override="sample_copy.pdf",
) )
assert resp2.status_code == 200 assert resp2.status_code == 200
@ -325,7 +325,7 @@ class TestSecondUploadExceedsCredit:
cleanup_doc_ids.extend(second_ids) cleanup_doc_ids.extend(second_ids)
statuses2 = await poll_document_status( 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: for did in second_ids:
assert statuses2[did]["status"]["state"] == "failed" assert statuses2[did]["status"]["state"] == "failed"

View file

@ -187,7 +187,7 @@ class TestStripeCheckoutSessionCreation:
self, self,
client, client,
headers, headers,
search_space_id: int, workspace_id: int,
monkeypatch, monkeypatch,
): ):
checkout_session = SimpleNamespace( checkout_session = SimpleNamespace(
@ -205,7 +205,7 @@ class TestStripeCheckoutSessionCreation:
response = await client.post( response = await client.post(
"/api/v1/stripe/create-credit-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, 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 assert response.status_code == 200, response.text
@ -217,12 +217,12 @@ class TestStripeCheckoutSessionCreation:
] ]
assert ( assert (
fake_client.last_params["success_url"] 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}" "?session_id={CHECKOUT_SESSION_ID}"
) )
assert ( assert (
fake_client.last_params["cancel_url"] 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" assert fake_client.last_params["metadata"]["purchase_type"] == "credits"
@ -244,7 +244,7 @@ class TestStripeCheckoutSessionCreation:
self, self,
client, client,
headers, headers,
search_space_id: int, workspace_id: int,
monkeypatch, monkeypatch,
): ):
monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False) monkeypatch.setattr(stripe_routes.config, "STRIPE_CREDIT_BUYING_ENABLED", False)
@ -252,7 +252,7 @@ class TestStripeCheckoutSessionCreation:
response = await client.post( response = await client.post(
"/api/v1/stripe/create-credit-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, 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 assert response.status_code == 503, response.text
@ -270,7 +270,7 @@ class TestStripeWebhookFulfillment:
self, self,
client, client,
headers, headers,
search_space_id: int, workspace_id: int,
credits, credits,
monkeypatch, monkeypatch,
): ):
@ -291,7 +291,7 @@ class TestStripeWebhookFulfillment:
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, 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 create_response.status_code == 200, create_response.text
@ -359,7 +359,7 @@ class TestStripeReconciliation:
self, self,
client, client,
headers, headers,
search_space_id: int, workspace_id: int,
credits, credits,
monkeypatch, monkeypatch,
): ):
@ -380,7 +380,7 @@ class TestStripeReconciliation:
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, 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 create_response.status_code == 200, create_response.text
assert await _get_balance(TEST_EMAIL) == 1_000_000 assert await _get_balance(TEST_EMAIL) == 1_000_000
@ -433,7 +433,7 @@ class TestStripeReconciliation:
self, self,
client, client,
headers, headers,
search_space_id: int, workspace_id: int,
credits, credits,
monkeypatch, monkeypatch,
): ):
@ -454,7 +454,7 @@ class TestStripeReconciliation:
create_response = await client.post( create_response = await client.post(
"/api/v1/stripe/create-credit-checkout-session", "/api/v1/stripe/create-credit-checkout-session",
headers=headers, 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 assert create_response.status_code == 200, create_response.text

View file

@ -34,14 +34,14 @@ class TestPerFileSizeLimit:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
): ):
oversized = io.BytesIO(b"\x00" * (500 * 1024 * 1024 + 1)) oversized = io.BytesIO(b"\x00" * (500 * 1024 * 1024 + 1))
resp = await client.post( resp = await client.post(
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
headers=headers, headers=headers,
files=[("files", ("big.pdf", oversized, "application/pdf"))], 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 resp.status_code == 413
assert "per-file limit" in resp.json()["detail"].lower() assert "per-file limit" in resp.json()["detail"].lower()
@ -50,7 +50,7 @@ class TestPerFileSizeLimit:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
at_limit = io.BytesIO(b"\x00" * (500 * 1024 * 1024)) at_limit = io.BytesIO(b"\x00" * (500 * 1024 * 1024))
@ -58,7 +58,7 @@ class TestPerFileSizeLimit:
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
headers=headers, headers=headers,
files=[("files", ("exact500mb.txt", at_limit, "text/plain"))], 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 assert resp.status_code == 200
cleanup_doc_ids.extend(resp.json().get("document_ids", [])) cleanup_doc_ids.extend(resp.json().get("document_ids", []))
@ -76,7 +76,7 @@ class TestNoFileCountLimit:
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
headers: dict[str, str], headers: dict[str, str],
search_space_id: int, workspace_id: int,
cleanup_doc_ids: list[int], cleanup_doc_ids: list[int],
): ):
files = [ files = [
@ -87,7 +87,7 @@ class TestNoFileCountLimit:
"/api/v1/documents/fileupload", "/api/v1/documents/fileupload",
headers=headers, headers=headers,
files=files, files=files,
data={"search_space_id": str(search_space_id)}, data={"workspace_id": str(workspace_id)},
) )
assert resp.status_code == 200 assert resp.status_code == 200
cleanup_doc_ids.extend(resp.json().get("document_ids", [])) cleanup_doc_ids.extend(resp.json().get("document_ids", []))

View file

@ -18,7 +18,7 @@ from app.db import (
DocumentType, DocumentType,
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
SearchSpace, Workspace,
User, User,
) )
@ -31,7 +31,7 @@ def make_document(
title: str, title: str,
document_type: DocumentType, document_type: DocumentType,
content: str, content: str,
search_space_id: int, workspace_id: int,
created_by_id: str, created_by_id: str,
) -> Document: ) -> Document:
"""Build a Document instance with unique hashes and a dummy embedding.""" """Build a Document instance with unique hashes and a dummy embedding."""
@ -43,7 +43,7 @@ def make_document(
content_hash=f"content-{uid}", content_hash=f"content-{uid}",
unique_identifier_hash=f"uid-{uid}", unique_identifier_hash=f"uid-{uid}",
source_markdown=content, source_markdown=content,
search_space_id=search_space_id, workspace_id=workspace_id,
created_by_id=created_by_id, created_by_id=created_by_id,
embedding=DUMMY_EMBEDDING, embedding=DUMMY_EMBEDDING,
updated_at=datetime.now(UTC), updated_at=datetime.now(UTC),
@ -66,35 +66,35 @@ def make_chunk(*, content: str, document_id: int) -> Chunk:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seed_google_docs( 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. """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``, 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) user_id = str(db_user.id)
space_id = db_search_space.id space_id = db_workspace.id
native_doc = make_document( native_doc = make_document(
title="Native Drive Document", title="Native Drive Document",
document_type=DocumentType.GOOGLE_DRIVE_FILE, document_type=DocumentType.GOOGLE_DRIVE_FILE,
content="quarterly report from native google drive connector", content="quarterly report from native google drive connector",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
legacy_doc = make_document( legacy_doc = make_document(
title="Legacy Composio Drive Document", title="Legacy Composio Drive Document",
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
content="quarterly report from 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, created_by_id=user_id,
) )
file_doc = make_document( file_doc = make_document(
title="Uploaded PDF", title="Uploaded PDF",
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content="unrelated uploaded file about quarterly reports", content="unrelated uploaded file about quarterly reports",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
@ -121,7 +121,7 @@ async def seed_google_docs(
"native_doc": native_doc, "native_doc": native_doc,
"legacy_doc": legacy_doc, "legacy_doc": legacy_doc,
"file_doc": file_doc, "file_doc": file_doc,
"search_space": db_search_space, "workspace": db_workspace,
"user": db_user, "user": db_user,
} }
@ -136,8 +136,8 @@ async def seed_google_docs(
async def committed_google_data(async_engine): async def committed_google_data(async_engine):
"""Insert native, legacy, and FILE docs via a committed transaction. """Insert native, legacy, and FILE docs via a committed transaction.
Yields ``{"search_space_id": int, "user_id": str}``. Yields ``{"workspace_id": int, "user_id": str}``.
Cleans up by deleting the search space (cascades to documents / chunks). Cleans up by deleting the workspace (cascades to documents / chunks).
""" """
space_id = None space_id = None
@ -155,7 +155,7 @@ async def committed_google_data(async_engine):
session.add(user) session.add(user)
await session.flush() 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) session.add(space)
await session.flush() await session.flush()
space_id = space.id space_id = space.id
@ -165,21 +165,21 @@ async def committed_google_data(async_engine):
title="Native Drive Doc", title="Native Drive Doc",
document_type=DocumentType.GOOGLE_DRIVE_FILE, document_type=DocumentType.GOOGLE_DRIVE_FILE,
content="quarterly budget from native google drive", content="quarterly budget from native google drive",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
legacy_doc = make_document( legacy_doc = make_document(
title="Legacy Composio Drive Doc", title="Legacy Composio Drive Doc",
document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, document_type=DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
content="quarterly budget from composio google drive", content="quarterly budget from composio google drive",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
file_doc = make_document( file_doc = make_document(
title="Plain File", title="Plain File",
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content="quarterly budget uploaded as file", content="quarterly budget uploaded as file",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
session.add_all([native_doc, legacy_doc, file_doc]) session.add_all([native_doc, legacy_doc, file_doc])
@ -195,7 +195,7 @@ async def committed_google_data(async_engine):
) )
await session.flush() 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: async with async_engine.begin() as conn:
await conn.execute( await conn.execute(
@ -257,7 +257,7 @@ async def seed_connector(
): ):
"""Seed a connector with committed data. Returns dict and cleanup function. """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 space_id = None
@ -275,7 +275,7 @@ async def seed_connector(
session.add(user) session.add(user)
await session.flush() await session.flush()
space = SearchSpace( space = Workspace(
name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id name=f"{name_prefix} {uuid.uuid4().hex[:6]}", user_id=user.id
) )
session.add(space) session.add(space)
@ -287,7 +287,7 @@ async def seed_connector(
connector_type=connector_type, connector_type=connector_type,
is_indexable=True, is_indexable=True,
config=config, config=config,
search_space_id=space_id, workspace_id=space_id,
user_id=user.id, user_id=user.id,
) )
session.add(connector) session.add(connector)
@ -297,13 +297,13 @@ async def seed_connector(
return { return {
"connector_id": connector_id, "connector_id": connector_id,
"search_space_id": space_id, "workspace_id": space_id,
"user_id": user_id, "user_id": user_id,
} }
async def cleanup_space(async_engine, space_id: int): 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: async with async_engine.begin() as conn:
await conn.execute( await conn.execute(
text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id} text("DELETE FROM workspaces WHERE id = :sid"), {"sid": space_id}

View file

@ -37,7 +37,7 @@ async def composio_calendar(async_engine):
name_prefix="cal-composio", name_prefix="cal-composio",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -49,7 +49,7 @@ async def composio_calendar_no_id(async_engine):
name_prefix="cal-noid", name_prefix="cal-noid",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -67,7 +67,7 @@ async def native_calendar(async_engine):
name_prefix="cal-native", name_prefix="cal-native",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN) @patch(_GET_ACCESS_TOKEN)
@ -98,7 +98,7 @@ async def test_composio_calendar_uses_composio_service(
await index_google_calendar_events( await index_google_calendar_events(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_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( count, _skipped, error = await index_google_calendar_events(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
) )
@ -179,7 +179,7 @@ async def test_native_calendar_uses_google_calendar_connector(
await index_google_calendar_events( await index_google_calendar_events(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
) )

View file

@ -62,7 +62,7 @@ async def committed_drive_connector(async_engine):
name_prefix="drive-composio", name_prefix="drive-composio",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -80,7 +80,7 @@ async def committed_native_drive_connector(async_engine):
name_prefix="drive-native", name_prefix="drive-native",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -92,7 +92,7 @@ async def committed_composio_no_account_id(async_engine):
name_prefix="drive-noid", name_prefix="drive-noid",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN) @patch(_GET_ACCESS_TOKEN)
@ -127,7 +127,7 @@ async def test_composio_drive_indexer_uses_composio_drive_client(
await index_google_drive_files( await index_google_drive_files(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
folder_id="test-folder-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( count, _skipped, error, _unsupported = await index_google_drive_files(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
folder_id="test-folder-id", folder_id="test-folder-id",
) )
@ -207,7 +207,7 @@ async def test_native_connector_uses_google_drive_client(
await index_google_drive_files( await index_google_drive_files(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
folder_id="test-folder-id", folder_id="test-folder-id",
) )

View file

@ -37,7 +37,7 @@ async def composio_gmail(async_engine):
name_prefix="gmail-composio", name_prefix="gmail-composio",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -49,7 +49,7 @@ async def composio_gmail_no_id(async_engine):
name_prefix="gmail-noid", name_prefix="gmail-noid",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -67,7 +67,7 @@ async def native_gmail(async_engine):
name_prefix="gmail-native", name_prefix="gmail-native",
) )
yield data yield data
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["workspace_id"])
@patch(_GET_ACCESS_TOKEN) @patch(_GET_ACCESS_TOKEN)
@ -101,7 +101,7 @@ async def test_composio_gmail_uses_composio_service(
await index_google_gmail_messages( await index_google_gmail_messages(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_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( count, _skipped, error = await index_google_gmail_messages(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
) )
@ -180,7 +180,7 @@ async def test_native_gmail_uses_google_gmail_connector(
await index_google_gmail_messages( await index_google_gmail_messages(
session=session, session=session,
connector_id=data["connector_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"], workspace_id=data["workspace_id"],
user_id=data["user_id"], user_id=data["user_id"],
) )

View file

@ -19,13 +19,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
db_session, seed_google_docs db_session, seed_google_docs
): ):
"""Searching with a list of document types returns documents of ALL listed types.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly report", query_text="quarterly report",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
document_type=["GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"], document_type=["GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
query_embedding=DUMMY_EMBEDDING, 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): 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.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly report", query_text="quarterly report",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
document_type="GOOGLE_DRIVE_FILE", document_type="GOOGLE_DRIVE_FILE",
query_embedding=DUMMY_EMBEDDING, 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): 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.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly report", query_text="quarterly report",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
document_type=["NONEXISTENT_TYPE"], document_type=["NONEXISTENT_TYPE"],
query_embedding=DUMMY_EMBEDDING, query_embedding=DUMMY_EMBEDDING,
) )

View file

@ -19,13 +19,13 @@ async def test_search_google_drive_includes_legacy_composio_docs(
async_engine, committed_google_data, patched_session_factory, patched_embed async_engine, committed_google_data, patched_session_factory, patched_embed
): ):
"""search_google_drive returns both GOOGLE_DRIVE_FILE and COMPOSIO_GOOGLE_DRIVE_CONNECTOR docs.""" """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: 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( _, raw_docs = await service.search_google_drive(
user_query="quarterly budget", user_query="quarterly budget",
search_space_id=space_id, workspace_id=space_id,
top_k=10, 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 async_engine, committed_google_data, patched_session_factory, patched_embed
): ):
"""search_files returns only FILE docs, not Google Drive docs.""" """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: 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( _, raw_docs = await service.search_files(
user_query="quarterly budget", user_query="quarterly budget",
search_space_id=space_id, workspace_id=space_id,
top_k=10, top_k=10,
) )

View file

@ -8,19 +8,19 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @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.""" """Document status is READY after successful indexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
await adapter.index( await adapter.index(
markdown_content="## Hello\n\nSome content.", markdown_content="## Hello\n\nSome content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = 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") @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.""" """Document content is set to the extracted source markdown."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
await adapter.index( await adapter.index(
markdown_content="## Hello\n\nSome content.", markdown_content="## Hello\n\nSome content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = 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") @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.""" """Chunks derived from the source markdown are persisted in the DB."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
await adapter.index( await adapter.index(
markdown_content="## Hello\n\nSome content.", markdown_content="## Hello\n\nSome content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = 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") @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.""" """RuntimeError is raised when the indexing step fails so the caller can fire a failure notification."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
with pytest.raises(RuntimeError, match=r"Embedding failed|Indexing failed"): 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.", markdown_content="## Hello\n\nSome content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.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") @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.""" """Document content is updated to the new source markdown after reindexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
await adapter.index( await adapter.index(
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
@ -120,7 +120,7 @@ async def test_reindex_updates_content(db_session, db_search_space, db_user, moc
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_updates_content_hash( async def test_reindex_updates_content_hash(
db_session, db_search_space, db_user, mocker db_session, db_workspace, db_user, mocker
): ):
"""Content hash is recomputed after reindexing with new source markdown.""" """Content hash is recomputed after reindexing with new source markdown."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -128,12 +128,12 @@ async def test_reindex_updates_content_hash(
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
original_hash = document.content_hash original_hash = document.content_hash
@ -148,19 +148,19 @@ async def test_reindex_updates_content_hash(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @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.""" """Document status is READY after successful reindexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
await adapter.index( await adapter.index(
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
@ -174,7 +174,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
@pytest.mark.usefixtures("patched_embed_texts") @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.""" """Reindexing replaces old chunks with new content rather than appending."""
mocker.patch( mocker.patch(
"app.indexing_pipeline.cache.cached_indexing.chunk_text_hybrid", "app.indexing_pipeline.cache.cached_indexing.chunk_text_hybrid",
@ -186,12 +186,12 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
document_id = document.id document_id = document.id
@ -212,7 +212,7 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_clears_reindexing_flag( 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.""" """After successful reindex, content_needs_reindexing is False."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -220,12 +220,12 @@ async def test_reindex_clears_reindexing_flag(
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
@ -241,7 +241,7 @@ async def test_reindex_clears_reindexing_flag(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_raises_on_failure( 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.""" """RuntimeError is raised when reindexing fails so the caller can handle it."""
@ -250,12 +250,12 @@ async def test_reindex_raises_on_failure(
markdown_content="## Original\n\nOriginal content.", markdown_content="## Original\n\nOriginal content.",
filename="test.pdf", filename="test.pdf",
etl_service="UNSTRUCTURED", etl_service="UNSTRUCTURED",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
) )
result = await db_session.execute( 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 = result.scalars().first()
@ -269,7 +269,7 @@ async def test_reindex_raises_on_failure(
async def test_reindex_raises_on_empty_source_markdown( 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.""" """Reindexing a document with no source_markdown raises immediately."""
from app.db import DocumentType from app.db import DocumentType
@ -281,7 +281,7 @@ async def test_reindex_raises_on_empty_source_markdown(
content_hash="abc123", content_hash="abc123",
unique_identifier_hash="def456", unique_identifier_hash="def456",
source_markdown="", source_markdown="",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=str(db_user.id), created_by_id=str(db_user.id),
) )
db_session.add(document) db_session.add(document)

View file

@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
def _cal_doc( 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: ) -> ConnectorDocument:
return ConnectorDocument( return ConnectorDocument(
title=f"Event {unique_id}", title=f"Event {unique_id}",
source_markdown=f"## Calendar Event\n\nDetails for {unique_id}", source_markdown=f"## Calendar Event\n\nDetails for {unique_id}",
unique_id=unique_id, unique_id=unique_id,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR, document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
metadata={ metadata={
@ -36,13 +36,13 @@ def _cal_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_pipeline_creates_ready_document( 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.""" """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( doc = _cal_doc(
unique_id="evt-1", unique_id="evt-1",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.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) await service.index(prepared[0], doc)
result = await db_session.execute( 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() 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_legacy_doc_migrated( 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.""" """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) user_id = str(db_user.id)
evt_id = "evt-legacy-cal" evt_id = "evt-legacy-cal"
@ -82,7 +82,7 @@ async def test_calendar_legacy_doc_migrated(
content_hash=f"ch-{legacy_hash[:12]}", content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash, unique_identifier_hash=legacy_hash,
source_markdown="## Old event", source_markdown="## Old event",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM, embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"}, status={"state": "ready"},
@ -93,7 +93,7 @@ async def test_calendar_legacy_doc_migrated(
connector_doc = _cal_doc( connector_doc = _cal_doc(
unique_id=evt_id, unique_id=evt_id,
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )

View file

@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
def _drive_doc( 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: ) -> ConnectorDocument:
return ConnectorDocument( return ConnectorDocument(
title=f"File {unique_id}.pdf", title=f"File {unique_id}.pdf",
source_markdown=f"## Document Content\n\nText from file {unique_id}", source_markdown=f"## Document Content\n\nText from file {unique_id}",
unique_id=unique_id, unique_id=unique_id,
document_type=DocumentType.GOOGLE_DRIVE_FILE, document_type=DocumentType.GOOGLE_DRIVE_FILE,
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
metadata={ metadata={
@ -35,13 +35,13 @@ def _drive_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_drive_pipeline_creates_ready_document( 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.""" """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( doc = _drive_doc(
unique_id="file-abc", unique_id="file-abc",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.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) await service.index(prepared[0], doc)
result = await db_session.execute( 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() 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_drive_legacy_doc_migrated( 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.""" """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) user_id = str(db_user.id)
file_id = "file-legacy-drive" file_id = "file-legacy-drive"
@ -81,7 +81,7 @@ async def test_drive_legacy_doc_migrated(
content_hash=f"ch-{legacy_hash[:12]}", content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash, unique_identifier_hash=legacy_hash,
source_markdown="## Old file content", source_markdown="## Old file content",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM, embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"}, status={"state": "ready"},
@ -92,7 +92,7 @@ async def test_drive_legacy_doc_migrated(
connector_doc = _drive_doc( connector_doc = _drive_doc(
unique_id=file_id, unique_id=file_id,
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_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( async def test_should_skip_file_skips_failed_document(
db_session, db_session,
db_search_space, db_workspace,
db_user, db_user,
): ):
"""A FAILED document with unchanged md5 must be skipped — user can manually retry via Quick Index.""" """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: if stub:
sys.modules.pop(pkg, None) sys.modules.pop(pkg, None)
space_id = db_search_space.id space_id = db_workspace.id
file_id = "file-failed-drive" file_id = "file-failed-drive"
md5 = "abc123deadbeef" md5 = "abc123deadbeef"
@ -153,7 +153,7 @@ async def test_should_skip_file_skips_failed_document(
content_hash=f"ch-{doc_hash[:12]}", content_hash=f"ch-{doc_hash[:12]}",
unique_identifier_hash=doc_hash, unique_identifier_hash=doc_hash,
source_markdown="## Real content", source_markdown="## Real content",
search_space_id=space_id, workspace_id=space_id,
created_by_id=str(db_user.id), created_by_id=str(db_user.id),
embedding=[0.1] * _EMBEDDING_DIM, embedding=[0.1] * _EMBEDDING_DIM,
status=DocumentStatus.failed("LLM rate limit exceeded"), 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"]) @pytest.mark.parametrize("stuck_state", ["pending", "processing"])
async def test_should_skip_file_retries_stuck_document( async def test_should_skip_file_retries_stuck_document(
db_session, db_session,
db_search_space, db_workspace,
db_user, db_user,
stuck_state, stuck_state,
): ):
@ -208,7 +208,7 @@ async def test_should_skip_file_retries_stuck_document(
if stub: if stub:
sys.modules.pop(pkg, None) sys.modules.pop(pkg, None)
space_id = db_search_space.id space_id = db_workspace.id
file_id = f"file-{stuck_state}-drive" file_id = f"file-{stuck_state}-drive"
md5 = "stuck123checksum" md5 = "stuck123checksum"
@ -227,7 +227,7 @@ async def test_should_skip_file_retries_stuck_document(
content_hash=f"ch-{doc_hash[:12]}", content_hash=f"ch-{doc_hash[:12]}",
unique_identifier_hash=doc_hash, unique_identifier_hash=doc_hash,
source_markdown="", source_markdown="",
search_space_id=space_id, workspace_id=space_id,
created_by_id=str(db_user.id), created_by_id=str(db_user.id),
status=status, status=status,
document_metadata={ document_metadata={

View file

@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _dropbox_doc( 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: ) -> ConnectorDocument:
return ConnectorDocument( return ConnectorDocument(
title=f"File {unique_id}.docx", title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}", source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id, unique_id=unique_id,
document_type=DocumentType.DROPBOX_FILE, document_type=DocumentType.DROPBOX_FILE,
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
metadata={ metadata={
@ -34,13 +34,13 @@ def _dropbox_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_pipeline_creates_ready_document( 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.""" """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( doc = _dropbox_doc(
unique_id="db-file-abc", unique_id="db-file-abc",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.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) await service.index(prepared[0], doc)
result = await db_session.execute( 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() 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_duplicate_content_skipped( 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).""" """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) user_id = str(db_user.id)
doc = _dropbox_doc( doc = _dropbox_doc(
unique_id="db-dup-file", unique_id="db-dup-file",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )
@ -83,13 +83,13 @@ async def test_dropbox_duplicate_content_skipped(
await service.index(prepared[0], doc) await service.index(prepared[0], doc)
result = await db_session.execute( 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() first_doc = result.scalars().first()
assert first_doc is not None assert first_doc is not None
doc2 = _dropbox_doc( doc2 = _dropbox_doc(
unique_id="db-dup-file", unique_id="db-dup-file",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )

View file

@ -17,7 +17,7 @@ pytestmark = pytest.mark.integration
def _gmail_doc( 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: ) -> ConnectorDocument:
"""Build a Gmail-style ConnectorDocument like the real indexer does.""" """Build a Gmail-style ConnectorDocument like the real indexer does."""
return ConnectorDocument( return ConnectorDocument(
@ -25,7 +25,7 @@ def _gmail_doc(
source_markdown=f"## Email\n\nBody of {unique_id}", source_markdown=f"## Email\n\nBody of {unique_id}",
unique_id=unique_id, unique_id=unique_id,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
metadata={ metadata={
@ -38,13 +38,13 @@ def _gmail_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_pipeline_creates_ready_document( 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.""" """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( doc = _gmail_doc(
unique_id="msg-pipeline-1", unique_id="msg-pipeline-1",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.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) await service.index(prepared[0], doc)
result = await db_session.execute( 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() 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_legacy_doc_migrated_then_reused( 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.""" """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) user_id = str(db_user.id)
msg_id = "msg-legacy-gmail" 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]}", content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash, unique_identifier_hash=legacy_hash,
source_markdown="## Old content", source_markdown="## Old content",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM, embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"}, status={"state": "ready"},
@ -96,7 +96,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
connector_doc = _gmail_doc( connector_doc = _gmail_doc(
unique_id=msg_id, unique_id=msg_id,
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )

View file

@ -11,21 +11,21 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_index_batch_creates_ready_documents( 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.""" """index_batch prepares and indexes a batch, resulting in READY documents."""
space_id = db_search_space.id space_id = db_workspace.id
docs = [ docs = [
make_connector_document( make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-batch-1", unique_id="msg-batch-1",
search_space_id=space_id, workspace_id=space_id,
source_markdown="## Email 1\n\nBody", source_markdown="## Email 1\n\nBody",
), ),
make_connector_document( make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-batch-2", unique_id="msg-batch-2",
search_space_id=space_id, workspace_id=space_id,
source_markdown="## Email 2\n\nDifferent body", source_markdown="## Email 2\n\nDifferent body",
), ),
] ]
@ -36,7 +36,7 @@ async def test_index_batch_creates_ready_documents(
assert len(results) == 2 assert len(results) == 2
result = await db_session.execute( 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() rows = result.scalars().all()
assert len(rows) == 2 assert len(rows) == 2

View file

@ -13,12 +13,12 @@ pytestmark = pytest.mark.integration
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_sets_status_ready( async def test_sets_status_ready(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Document status is READY after successful indexing.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_content_is_source_markdown_by_default( async def test_content_is_source_markdown_by_default(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Document content is set to source_markdown by default.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_content_is_source_markdown_when_custom_content( async def test_content_is_source_markdown_when_custom_content(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
): ):
"""Document content is set to source_markdown verbatim.""" """Document content is set to source_markdown verbatim."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## Raw content", source_markdown="## Raw content",
) )
service = IndexingPipelineService(session=db_session) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_chunks_written_to_db( async def test_chunks_written_to_db(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Chunks derived from source_markdown are persisted in the DB.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_embedding_written_to_db( async def test_embedding_written_to_db(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Document embedding vector is persisted in the DB after indexing.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_updated_at_advances_after_indexing( async def test_updated_at_advances_after_indexing(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""updated_at timestamp is later after indexing than it was at prepare time.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_no_llm_falls_back_to_source_markdown( async def test_no_llm_falls_back_to_source_markdown(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
): ):
"""Content stays deterministic source markdown without an LLM.""" """Content stays deterministic source markdown without an LLM."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## Fallback content", source_markdown="## Fallback content",
) )
service = IndexingPipelineService(session=db_session) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_source_markdown_used_without_preview( async def test_source_markdown_used_without_preview(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
): ):
"""Source markdown is used without fallback preview fields.""" """Source markdown is used without fallback preview fields."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## Full raw content", source_markdown="## Full raw content",
) )
service = IndexingPipelineService(session=db_session) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_reindex_replaces_old_chunks( async def test_reindex_replaces_old_chunks(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Re-indexing a document replaces its old chunks rather than appending.""" """Re-indexing a document replaces its old chunks rather than appending."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## v1", source_markdown="## v1",
) )
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
@ -245,7 +245,7 @@ async def test_reindex_replaces_old_chunks(
await service.index(document, connector_doc) await service.index(document, connector_doc)
updated_doc = make_connector_document( updated_doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## v2", source_markdown="## v2",
) )
re_prepared = await service.prepare_for_indexing([updated_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_embedding_error_sets_status_failed( async def test_embedding_error_sets_status_failed(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""Document status is FAILED when embedding raises during indexing.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) 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") @pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_embedding_error_leaves_no_partial_data( async def test_embedding_error_leaves_no_partial_data(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""A failed indexing attempt leaves no partial embedding or chunks in the DB.""" """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) service = IndexingPipelineService(session=db_session)
prepared = await service.prepare_for_indexing([connector_doc]) prepared = await service.prepare_for_indexing([connector_doc])

View file

@ -50,13 +50,13 @@ async def _load_chunks(db_session, document_id):
@pytest.mark.usefixtures("paragraph_chunker") @pytest.mark.usefixtures("paragraph_chunker")
async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text( async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
patched_embed_texts, patched_embed_texts,
): ):
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
doc_v1 = make_connector_document( doc_v1 = make_connector_document(
search_space_id=db_search_space.id, source_markdown=_V1 workspace_id=db_workspace.id, source_markdown=_V1
) )
document = await _index(service, doc_v1) document = await _index(service, doc_v1)
@ -65,7 +65,7 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
edited = "Intro paragraph.\n\nBody paragraph EDITED.\n\nOutro paragraph." edited = "Intro paragraph.\n\nBody paragraph EDITED.\n\nOutro paragraph."
doc_v2 = make_connector_document( 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) await _index(service, doc_v2)
@ -94,14 +94,14 @@ async def test_edit_keeps_unchanged_rows_and_embeds_only_the_new_text(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts") @pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_head_insert_shifts_positions_without_new_rows_for_old_text( async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
): ):
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
document = await _index( document = await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, source_markdown=_V1 workspace_id=db_workspace.id, source_markdown=_V1
), ),
) )
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)} ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
@ -109,7 +109,7 @@ async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
await _index( await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="Brand new opener.\n\n" + _V1, source_markdown="Brand new opener.\n\n" + _V1,
), ),
) )
@ -130,14 +130,14 @@ async def test_head_insert_shifts_positions_without_new_rows_for_old_text(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts") @pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_removed_paragraph_is_deleted_and_order_compacts( async def test_removed_paragraph_is_deleted_and_order_compacts(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
): ):
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
document = await _index( document = await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, source_markdown=_V1 workspace_id=db_workspace.id, source_markdown=_V1
), ),
) )
ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)} ids_v1 = {c.content: c.id for c in await _load_chunks(db_session, document.id)}
@ -145,7 +145,7 @@ async def test_removed_paragraph_is_deleted_and_order_compacts(
await _index( await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="Intro paragraph.\n\nOutro paragraph.", source_markdown="Intro paragraph.\n\nOutro paragraph.",
), ),
) )
@ -162,7 +162,7 @@ async def test_removed_paragraph_is_deleted_and_order_compacts(
@pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts") @pytest.mark.usefixtures("paragraph_chunker", "patched_embed_texts")
async def test_kill_switch_falls_back_to_full_replace( async def test_kill_switch_falls_back_to_full_replace(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
monkeypatch, monkeypatch,
): ):
@ -172,7 +172,7 @@ async def test_kill_switch_falls_back_to_full_replace(
document = await _index( document = await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, source_markdown=_V1 workspace_id=db_workspace.id, source_markdown=_V1
), ),
) )
ids_v1 = {c.id for c in await _load_chunks(db_session, document.id)} ids_v1 = {c.id for c in await _load_chunks(db_session, document.id)}
@ -181,7 +181,7 @@ async def test_kill_switch_falls_back_to_full_replace(
await _index( await _index(
service, service,
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown=_V1 + "\n\nAppended paragraph.", source_markdown=_V1 + "\n\nAppended paragraph.",
), ),
) )

View file

@ -14,7 +14,7 @@ from app.db import (
DocumentType, DocumentType,
DocumentVersion, DocumentVersion,
Folder, Folder,
SearchSpace, Workspace,
User, User,
) )
@ -66,7 +66,7 @@ class TestFullIndexer:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""I1: Single new .md file is indexed with status READY.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -90,7 +90,7 @@ class TestFullIndexer:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""I2: Second run on unchanged directory creates no new documents.""" """I2: Second run on unchanged directory creates no new documents."""
@ -116,7 +116,7 @@ class TestFullIndexer:
count1, _, root_folder_id, _ = await index_local_folder( count1, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -125,7 +125,7 @@ class TestFullIndexer:
count2, _, _, _ = await index_local_folder( count2, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -139,7 +139,7 @@ class TestFullIndexer:
.select_from(Document) .select_from(Document)
.where( .where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -150,7 +150,7 @@ class TestFullIndexer:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""I3: Modified file content triggers re-index and creates a version.""" """I3: Modified file content triggers re-index and creates a version."""
@ -161,7 +161,7 @@ class TestFullIndexer:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -172,7 +172,7 @@ class TestFullIndexer:
count, _, _, _ = await index_local_folder( count, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -187,7 +187,7 @@ class TestFullIndexer:
.join(Document) .join(Document)
.where( .where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""I4: Deleted file is removed from DB on re-sync.""" """I4: Deleted file is removed from DB on re-sync."""
@ -212,7 +212,7 @@ class TestFullIndexer:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -224,7 +224,7 @@ class TestFullIndexer:
.select_from(Document) .select_from(Document)
.where( .where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -234,7 +234,7 @@ class TestFullIndexer:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -247,7 +247,7 @@ class TestFullIndexer:
.select_from(Document) .select_from(Document)
.where( .where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -258,7 +258,7 @@ class TestFullIndexer:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""I5: Batch mode with a single file only processes that file.""" """I5: Batch mode with a single file only processes that file."""
@ -270,7 +270,7 @@ class TestFullIndexer:
count, _, _, _ = await index_local_folder( count, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -283,7 +283,7 @@ class TestFullIndexer:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F1: First sync creates a root Folder and returns root_folder_id.""" """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( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -333,7 +333,7 @@ class TestFolderMirroring:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F2: Nested dirs create Folder rows with correct parent_id chain.""" """F2: Nested dirs create Folder rows with correct parent_id chain."""
@ -348,7 +348,7 @@ class TestFolderMirroring:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -357,7 +357,7 @@ class TestFolderMirroring:
folders = ( folders = (
( (
await db_session.execute( 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() .scalars()
@ -381,7 +381,7 @@ class TestFolderMirroring:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F3: Re-sync reuses existing Folder rows, no duplicates.""" """F3: Re-sync reuses existing Folder rows, no duplicates."""
@ -393,7 +393,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -402,7 +402,7 @@ class TestFolderMirroring:
folders_before = ( folders_before = (
( (
await db_session.execute( 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() .scalars()
@ -412,7 +412,7 @@ class TestFolderMirroring:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -422,7 +422,7 @@ class TestFolderMirroring:
folders_after = ( folders_after = (
( (
await db_session.execute( 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() .scalars()
@ -437,7 +437,7 @@ class TestFolderMirroring:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F4: Documents get correct folder_id based on their directory.""" """F4: Documents get correct folder_id based on their directory."""
@ -450,7 +450,7 @@ class TestFolderMirroring:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -461,7 +461,7 @@ class TestFolderMirroring:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F5: Deleted dir's empty Folder row is cleaned up on re-sync.""" """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( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -517,7 +517,7 @@ class TestFolderMirroring:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -539,7 +539,7 @@ class TestFolderMirroring:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F6: Single-file mode creates missing Folder rows and assigns correct folder_id.""" """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( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -561,7 +561,7 @@ class TestFolderMirroring:
count, _, _, _ = await index_local_folder( count, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -597,7 +597,7 @@ class TestFolderMirroring:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""F7: Deleting the only file in a subfolder via batch mode removes empty Folder rows.""" """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( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -626,7 +626,7 @@ class TestFolderMirroring:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -656,7 +656,7 @@ class TestBatchMode:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
patched_batch_sessions, patched_batch_sessions,
): ):
@ -669,7 +669,7 @@ class TestBatchMode:
count, failed, _root_folder_id, err = await index_local_folder( count, failed, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -689,7 +689,7 @@ class TestBatchMode:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
patched_batch_sessions, patched_batch_sessions,
): ):
@ -720,7 +720,7 @@ class TestBatchMode:
count, failed, _, err = await index_local_folder( count, failed, _, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -740,7 +740,7 @@ class TestBatchMode:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
mocker, mocker,
): ):
"""P1: LOCAL_FOLDER_FILE ConnectorDocument through prepare+index to READY.""" """P1: LOCAL_FOLDER_FILE ConnectorDocument through prepare+index to READY."""
@ -776,7 +776,7 @@ class TestPipelineIntegration:
source_markdown="## Local file\n\nContent from disk.", source_markdown="## Local file\n\nContent from disk.",
unique_id="test-folder:test.md", unique_id="test-folder:test.md",
document_type=DocumentType.LOCAL_FOLDER_FILE, document_type=DocumentType.LOCAL_FOLDER_FILE,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
connector_id=None, connector_id=None,
created_by_id=str(db_user.id), created_by_id=str(db_user.id),
) )
@ -794,7 +794,7 @@ class TestPipelineIntegration:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, 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, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""DC1: CSV file is indexed as a markdown table, not raw comma-separated text.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -839,7 +839,7 @@ class TestDirectConvert:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -853,7 +853,7 @@ class TestDirectConvert:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""DC2: TSV file is indexed as a markdown table.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -878,7 +878,7 @@ class TestDirectConvert:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -891,7 +891,7 @@ class TestDirectConvert:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""DC3: HTML file is indexed as clean markdown, not raw HTML.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -914,7 +914,7 @@ class TestDirectConvert:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -927,7 +927,7 @@ class TestDirectConvert:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""DC4: CSV via single-file batch mode also produces a markdown table.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -951,7 +951,7 @@ class TestDirectConvert:
await db_session.execute( await db_session.execute(
select(Document).where( select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE, Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id, Document.workspace_id == db_workspace.id,
) )
) )
).scalar_one() ).scalar_one()
@ -984,7 +984,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""CR1: Successful full-scan sync debits user.credit_micros_balance.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1017,7 +1017,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""CR2: Full-scan skips file when the wallet is empty.""" """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( count, _skipped, _root_folder_id, _err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1048,7 +1048,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""CR3: Single-file mode debits balance on success.""" """CR3: Single-file mode debits balance on success."""
@ -1062,7 +1062,7 @@ class TestEtlCredits:
count, _skipped, _root_folder_id, err = await index_local_folder( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1082,7 +1082,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""CR4: Single-file mode skips file when the wallet is empty.""" """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( count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1116,7 +1116,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""CR5: Re-syncing an unchanged file does not consume additional credit.""" """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( count1, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1142,7 +1142,7 @@ class TestEtlCredits:
count2, _, _, _ = await index_local_folder( count2, _, _, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1160,7 +1160,7 @@ class TestEtlCredits:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
patched_batch_sessions, patched_batch_sessions,
): ):
@ -1177,7 +1177,7 @@ class TestEtlCredits:
count, failed, _root_folder_id, _err = await index_local_folder( count, failed, _root_folder_id, _err = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1209,7 +1209,7 @@ class TestIndexingProgressFlag:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""IP1: Full-scan mode clears indexing_in_progress after completion.""" """IP1: Full-scan mode clears indexing_in_progress after completion."""
@ -1219,7 +1219,7 @@ class TestIndexingProgressFlag:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1237,7 +1237,7 @@ class TestIndexingProgressFlag:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""IP2: Single-file (Chokidar) mode clears indexing_in_progress after completion.""" """IP2: Single-file (Chokidar) mode clears indexing_in_progress after completion."""
@ -1246,7 +1246,7 @@ class TestIndexingProgressFlag:
(tmp_path / "root.md").write_text("root") (tmp_path / "root.md").write_text("root")
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1256,7 +1256,7 @@ class TestIndexingProgressFlag:
await index_local_folder( await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",
@ -1275,7 +1275,7 @@ class TestIndexingProgressFlag:
self, self,
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
tmp_path: Path, tmp_path: Path,
): ):
"""IP3: indexing_in_progress is True on the root folder while indexing is running.""" """IP3: indexing_in_progress is True on the root folder while indexing is running."""
@ -1294,7 +1294,7 @@ class TestIndexingProgressFlag:
folder = ( folder = (
await db_session.execute( await db_session.execute(
select(Folder).where( select(Folder).where(
Folder.search_space_id == db_search_space.id, Folder.workspace_id == db_workspace.id,
Folder.parent_id.is_(None), Folder.parent_id.is_(None),
) )
) )
@ -1308,7 +1308,7 @@ class TestIndexingProgressFlag:
try: try:
_, _, root_folder_id, _ = await index_local_folder( _, _, root_folder_id, _ = await index_local_folder(
session=db_session, session=db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user_id=str(db_user.id), user_id=str(db_user.id),
folder_path=str(tmp_path), folder_path=str(tmp_path),
folder_name="test-folder", folder_name="test-folder",

View file

@ -20,14 +20,14 @@ pytestmark = pytest.mark.integration
async def _make_doc( async def _make_doc(
db_session, db_session,
*, *,
search_space_id: int, workspace_id: int,
connector_id: int, connector_id: int,
user_id: str, user_id: str,
file_id: str, file_id: str,
status: dict, status: dict,
) -> Document: ) -> Document:
uid_hash = compute_identifier_hash( 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( doc = Document(
title=f"{file_id}.pdf", title=f"{file_id}.pdf",
@ -36,7 +36,7 @@ async def _make_doc(
content_hash=hashlib.sha256(f"placeholder:{uid_hash}".encode()).hexdigest(), content_hash=hashlib.sha256(f"placeholder:{uid_hash}".encode()).hexdigest(),
unique_identifier_hash=uid_hash, unique_identifier_hash=uid_hash,
document_metadata={"google_drive_file_id": file_id}, document_metadata={"google_drive_file_id": file_id},
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
status=status, status=status,
@ -47,11 +47,11 @@ async def _make_doc(
async def test_pending_placeholder_marked_failed( 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( doc = await _make_doc(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.id), user_id=str(db_user.id),
file_id="file-pending", file_id="file-pending",
@ -61,7 +61,7 @@ async def test_pending_placeholder_marked_failed(
marked = await mark_connector_documents_failed( marked = await mark_connector_documents_failed(
db_session, db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE, 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")], 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( 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( doc = await _make_doc(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.id), user_id=str(db_user.id),
file_id="file-ready", file_id="file-ready",
@ -86,7 +86,7 @@ async def test_ready_document_not_clobbered(
marked = await mark_connector_documents_failed( marked = await mark_connector_documents_failed(
db_session, db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE, document_type=DocumentType.GOOGLE_DRIVE_FILE,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
failures=[("file-ready", "should be ignored")], 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) 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( marked = await mark_connector_documents_failed(
db_session, db_session,
document_type=DocumentType.GOOGLE_DRIVE_FILE, document_type=DocumentType.GOOGLE_DRIVE_FILE,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
failures=[("does-not-exist", "reason")], failures=[("does-not-exist", "reason")],
) )
assert marked == 0 assert marked == 0
result = await db_session.execute( 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 assert result.scalars().first() is None

View file

@ -14,10 +14,10 @@ pytestmark = pytest.mark.integration
async def test_legacy_composio_gmail_doc_migrated_in_db( 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.""" """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) user_id = str(db_user.id)
unique_id = "msg-legacy-123" unique_id = "msg-legacy-123"
@ -34,7 +34,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
content="legacy content", content="legacy content",
content_hash=f"ch-{legacy_hash[:12]}", content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash, unique_identifier_hash=legacy_hash,
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM, embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"}, status={"state": "ready"},
@ -46,7 +46,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=unique_id, unique_id=unique_id,
search_space_id=space_id, workspace_id=space_id,
) )
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
@ -60,32 +60,32 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
async def test_no_legacy_doc_is_noop( async def test_no_legacy_doc_is_noop(
db_session, db_search_space, make_connector_document db_session, db_workspace, make_connector_document
): ):
"""When no legacy document exists, migrate_legacy_docs does nothing.""" """When no legacy document exists, migrate_legacy_docs does nothing."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR, document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
unique_id="evt-no-legacy", unique_id="evt-no-legacy",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
await service.migrate_legacy_docs([connector_doc]) await service.migrate_legacy_docs([connector_doc])
result = await db_session.execute( 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() == [] assert result.scalars().all() == []
async def test_non_google_type_is_skipped( 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.""" """migrate_legacy_docs skips ConnectorDocuments that are not Google types."""
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
unique_id="task-1", unique_id="task-1",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)

View file

@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _onedrive_doc( 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: ) -> ConnectorDocument:
return ConnectorDocument( return ConnectorDocument(
title=f"File {unique_id}.docx", title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}", source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id, unique_id=unique_id,
document_type=DocumentType.ONEDRIVE_FILE, document_type=DocumentType.ONEDRIVE_FILE,
search_space_id=search_space_id, workspace_id=workspace_id,
connector_id=connector_id, connector_id=connector_id,
created_by_id=user_id, created_by_id=user_id,
metadata={ metadata={
@ -34,13 +34,13 @@ def _onedrive_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_pipeline_creates_ready_document( 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.""" """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( doc = _onedrive_doc(
unique_id="od-file-abc", unique_id="od-file-abc",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=str(db_user.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) await service.index(prepared[0], doc)
result = await db_session.execute( 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() 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_duplicate_content_skipped( 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).""" """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) user_id = str(db_user.id)
doc = _onedrive_doc( doc = _onedrive_doc(
unique_id="od-dup-file", unique_id="od-dup-file",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )
@ -83,13 +83,13 @@ async def test_onedrive_duplicate_content_skipped(
await service.index(prepared[0], doc) await service.index(prepared[0], doc)
result = await db_session.execute( 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() first_doc = result.scalars().first()
assert first_doc is not None assert first_doc is not None
doc2 = _onedrive_doc( doc2 = _onedrive_doc(
unique_id="od-dup-file", unique_id="od-dup-file",
search_space_id=space_id, workspace_id=space_id,
connector_id=db_connector.id, connector_id=db_connector.id,
user_id=user_id, user_id=user_id,
) )

View file

@ -11,10 +11,10 @@ pytestmark = pytest.mark.integration
async def test_new_document_is_persisted_with_pending_status( 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.""" """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) service = IndexingPipelineService(session=db_session)
results = await service.prepare_for_indexing([doc]) 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_unchanged_ready_document_is_skipped( async def test_unchanged_ready_document_is_skipped(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""A READY document with unchanged content is not returned for re-indexing.""" """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) service = IndexingPipelineService(session=db_session)
# Index fully so the document reaches ready state # 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") @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_title_only_change_updates_title_in_db( async def test_title_only_change_updates_title_in_db(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""A title-only change updates the DB title without re-queuing the document.""" """A title-only change updates the DB title without re-queuing the document."""
original = make_connector_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) 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) await service.index(prepared[0], original)
renamed = make_connector_document( 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]) 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( 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.""" """A document with changed content is returned for re-indexing with updated markdown."""
original = make_connector_document( 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) service = IndexingPipelineService(session=db_session)
@ -98,7 +98,7 @@ async def test_changed_content_is_returned_for_reprocessing(
original_id = first[0].id original_id = first[0].id
updated = make_connector_document( 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]) 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( 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.""" """All documents in a batch are persisted and returned."""
docs = [ docs = [
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="id-1", unique_id="id-1",
title="Doc 1", title="Doc 1",
source_markdown="## Content 1", source_markdown="## Content 1",
), ),
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="id-2", unique_id="id-2",
title="Doc 2", title="Doc 2",
source_markdown="## Content 2", source_markdown="## Content 2",
), ),
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="id-3", unique_id="id-3",
title="Doc 3", title="Doc 3",
source_markdown="## Content 3", source_markdown="## Content 3",
@ -145,7 +145,7 @@ async def test_all_documents_in_batch_are_persisted(
assert len(results) == 3 assert len(results) == 3
result = await db_session.execute( 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() 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( 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.""" """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) service = IndexingPipelineService(session=db_session)
results = await service.prepare_for_indexing([doc, doc]) 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 assert len(results) == 1
result = await db_session.execute( 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() 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( 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.""" """created_by_id from the connector document is persisted on the DB row."""
doc = make_connector_document( doc = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=str(db_user.id), created_by_id=str(db_user.id),
) )
service = IndexingPipelineService(session=db_session) 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( 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.""" """document_metadata is overwritten with the latest metadata when content changes."""
original = make_connector_document( original = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## v1", source_markdown="## v1",
metadata={"status": "in_progress"}, metadata={"status": "in_progress"},
) )
@ -207,7 +207,7 @@ async def test_metadata_is_updated_when_content_changes(
document_id = first[0].id document_id = first[0].id
updated = make_connector_document( updated = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
source_markdown="## v2", source_markdown="## v2",
metadata={"status": "done"}, metadata={"status": "done"},
) )
@ -222,11 +222,11 @@ async def test_metadata_is_updated_when_content_changes(
async def test_updated_at_advances_when_title_only_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.""" """updated_at advances even when only the title changes."""
original = make_connector_document( original = make_connector_document(
search_space_id=db_search_space.id, title="Old Title" workspace_id=db_workspace.id, title="Old Title"
) )
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)
@ -239,7 +239,7 @@ async def test_updated_at_advances_when_title_only_changes(
updated_at_v1 = result.scalars().first().updated_at updated_at_v1 = result.scalars().first().updated_at
renamed = make_connector_document( renamed = make_connector_document(
search_space_id=db_search_space.id, title="New Title" workspace_id=db_workspace.id, title="New Title"
) )
await service.prepare_for_indexing([renamed]) await service.prepare_for_indexing([renamed])
@ -252,11 +252,11 @@ async def test_updated_at_advances_when_title_only_changes(
async def test_updated_at_advances_when_content_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.""" """updated_at advances when document content changes."""
original = make_connector_document( 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) service = IndexingPipelineService(session=db_session)
@ -269,7 +269,7 @@ async def test_updated_at_advances_when_content_changes(
updated_at_v1 = result.scalars().first().updated_at updated_at_v1 = result.scalars().first().updated_at
updated = make_connector_document( 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]) await service.prepare_for_indexing([updated])
@ -282,16 +282,16 @@ async def test_updated_at_advances_when_content_changes(
async def test_same_content_from_different_source_skipped_in_single_batch( 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.""" """Two documents with identical content in the same batch result in only one being persisted."""
first = make_connector_document( first = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="source-a", unique_id="source-a",
source_markdown="## Shared content", source_markdown="## Shared content",
) )
second = make_connector_document( second = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="source-b", unique_id="source-b",
source_markdown="## Shared content", source_markdown="## Shared content",
) )
@ -302,22 +302,22 @@ async def test_same_content_from_different_source_skipped_in_single_batch(
assert len(results) == 1 assert len(results) == 1
result = await db_session.execute( 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 assert len(result.scalars().all()) == 1
async def test_same_content_from_different_source_is_skipped( 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.""" """A document with content identical to an already-indexed document is skipped."""
first = make_connector_document( first = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="source-a", unique_id="source-a",
source_markdown="## Shared content", source_markdown="## Shared content",
) )
second = make_connector_document( second = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="source-b", unique_id="source-b",
source_markdown="## Shared content", source_markdown="## Shared content",
) )
@ -329,7 +329,7 @@ async def test_same_content_from_different_source_is_skipped(
assert results == [] assert results == []
result = await db_session.execute( 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 assert len(result.scalars().all()) == 1
@ -337,12 +337,12 @@ async def test_same_content_from_different_source_is_skipped(
@pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text") @pytest.mark.usefixtures("patched_embed_texts_raises", "patched_chunk_text")
async def test_failed_document_with_unchanged_content_is_requeued( async def test_failed_document_with_unchanged_content_is_requeued(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
mocker, mocker,
): ):
"""A FAILED document with unchanged content is re-queued as PENDING on the next run.""" """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) service = IndexingPipelineService(session=db_session)
# First run: document is created and indexing crashes, so status becomes failed. # First run: document is created and indexing crashes, so status becomes failed.
@ -372,11 +372,11 @@ async def test_failed_document_with_unchanged_content_is_requeued(
async def test_title_and_content_change_updates_both_and_returns_document( 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.""" """When both title and content change, both are updated and the document is returned for re-indexing."""
original = make_connector_document( original = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Original Title", title="Original Title",
source_markdown="## v1", source_markdown="## v1",
) )
@ -386,7 +386,7 @@ async def test_title_and_content_change_updates_both_and_returns_document(
original_id = first[0].id original_id = first[0].id
updated = make_connector_document( updated = make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
title="Updated Title", title="Updated Title",
source_markdown="## v2", source_markdown="## v2",
) )
@ -406,7 +406,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( async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_persisted(
db_session, db_session,
db_search_space, db_workspace,
make_connector_document, make_connector_document,
monkeypatch, monkeypatch,
): ):
@ -416,17 +416,17 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
""" """
docs = [ docs = [
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="good-1", unique_id="good-1",
source_markdown="## Good doc 1", source_markdown="## Good doc 1",
), ),
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="will-fail", unique_id="will-fail",
source_markdown="## Bad doc", source_markdown="## Bad doc",
), ),
make_connector_document( make_connector_document(
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
unique_id="good-2", unique_id="good-2",
source_markdown="## Good doc 2", source_markdown="## Good doc 2",
), ),
@ -448,6 +448,6 @@ async def test_one_bad_document_in_batch_does_not_prevent_others_from_being_pers
assert len(results) == 2 assert len(results) == 2
result = await db_session.execute( 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 assert len(result.scalars().all()) == 2

View file

@ -1,7 +1,7 @@
"""Behavior guard for the shared find/upsert/update logic (BaseNotificationHandler). """Behavior guard for the shared find/upsert/update logic (BaseNotificationHandler).
Uses the connector-indexing handler instance to drive the base methods against 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 from __future__ import annotations
@ -10,7 +10,7 @@ import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.persistence import Notification from app.notifications.persistence import Notification
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
@ -22,7 +22,7 @@ handler = NotificationService.connector_indexing
async def test_find_or_create_creates_with_progress_metadata( async def test_find_or_create_creates_with_progress_metadata(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Creating a notification seeds operation id, in-progress status, and start time.""" """Creating a notification seeds operation id, in-progress status, and start time."""
notification = await handler.find_or_create_notification( 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", operation_id="op-create",
title="Title", title="Title",
message="Message", message="Message",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert notification.notification_metadata["operation_id"] == "op-create" 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( async def test_find_or_create_upserts_same_operation(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Reusing an operation id updates the same row instead of creating a duplicate.""" """Reusing an operation id updates the same row instead of creating a duplicate."""
first = await handler.find_or_create_notification( first = await handler.find_or_create_notification(
@ -51,7 +51,7 @@ async def test_find_or_create_upserts_same_operation(
operation_id="op-upsert", operation_id="op-upsert",
title="First", title="First",
message="First message", message="First message",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
second = await handler.find_or_create_notification( second = await handler.find_or_create_notification(
@ -60,7 +60,7 @@ async def test_find_or_create_upserts_same_operation(
operation_id="op-upsert", operation_id="op-upsert",
title="Second", title="Second",
message="Second message", message="Second message",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert second.id == first.id assert second.id == first.id
@ -76,22 +76,22 @@ async def test_find_or_create_upserts_same_operation(
assert count == 1 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_session: AsyncSession,
db_user: User, 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( await handler.find_or_create_notification(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
operation_id="op-scoped", operation_id="op-scoped",
title="Title", title="Title",
message="Message", 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) db_session.add(other_space)
await db_session.flush() await db_session.flush()
@ -99,7 +99,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
operation_id="op-scoped", operation_id="op-scoped",
search_space_id=other_space.id, workspace_id=other_space.id,
) )
assert found_other is None assert found_other is None
@ -107,7 +107,7 @@ async def test_find_by_operation_is_scoped_to_search_space(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
operation_id="op-scoped", operation_id="op-scoped",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert found_same is not None 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( async def test_update_notification_completed_stamps_completed_at(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Completing a notification stamps completed_at and merges metadata updates.""" """Completing a notification stamps completed_at and merges metadata updates."""
notification = await handler.find_or_create_notification( notification = await handler.find_or_create_notification(
@ -124,7 +124,7 @@ async def test_update_notification_completed_stamps_completed_at(
operation_id="op-complete", operation_id="op-complete",
title="Title", title="Title",
message="Message", message="Message",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
updated = await handler.update_notification( 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( async def test_update_notification_failed_stamps_completed_at(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Failing a notification also stamps completed_at for the terminal state.""" """Failing a notification also stamps completed_at for the terminal state."""
notification = await handler.find_or_create_notification( notification = await handler.find_or_create_notification(
@ -151,7 +151,7 @@ async def test_update_notification_failed_stamps_completed_at(
operation_id="op-fail", operation_id="op-fail",
title="Title", title="Title",
message="Message", message="Message",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
updated = await handler.update_notification( updated = await handler.update_notification(

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
handler = NotificationService.comment_reply 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.""" """Raise a comment-reply notification for the assertions in the tests below."""
return await handler.notify_comment_reply( return await handler.notify_comment_reply(
session=db_session, 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_avatar_url=None,
author_email="bob@surfsense.net", author_email="bob@surfsense.net",
content_preview=preview, content_preview=preview,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
async def test_comment_reply_title_and_message( 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.""" """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.type == "comment_reply"
assert notification.title == "Bob replied in a thread" assert notification.title == "Bob replied in a thread"
@ -44,21 +44,21 @@ async def test_comment_reply_title_and_message(
async def test_comment_reply_truncates_long_preview( 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.""" """A long comment preview is truncated in the reply message."""
notification = await _notify( notification = await _notify(
db_session, db_user, db_search_space, preview="y" * 150 db_session, db_user, db_workspace, preview="y" * 150
) )
assert notification.message == "y" * 100 + "..." assert notification.message == "y" * 100 + "..."
async def test_comment_reply_is_idempotent( 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.""" """Re-notifying the same reply id reuses the existing notification row."""
first = 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_search_space, reply_id=5) second = await _notify(db_session, db_user, db_workspace, reply_id=5)
assert second.id == first.id assert second.id == first.id

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -19,7 +19,7 @@ pytestmark = pytest.mark.integration
async def test_indexing_started_opens_notification( async def test_indexing_started_opens_notification(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Starting indexing opens an unread notification with connecting-stage metadata.""" """Starting indexing opens an unread notification with connecting-stage metadata."""
notification = await NotificationService.connector_indexing.notify_indexing_started( notification = await NotificationService.connector_indexing.notify_indexing_started(
@ -28,7 +28,7 @@ async def test_indexing_started_opens_notification(
connector_id=42, connector_id=42,
connector_name="Notion - My Workspace", connector_name="Notion - My Workspace",
connector_type="NOTION_CONNECTOR", connector_type="NOTION_CONNECTOR",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert notification.id is not None assert notification.id is not None
@ -50,7 +50,7 @@ async def test_indexing_started_opens_notification(
async def _started( async def _started(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
*, *,
connector_name: str = "Notion - My Workspace", connector_name: str = "Notion - My Workspace",
): ):
@ -61,17 +61,17 @@ async def _started(
connector_id=42, connector_id=42,
connector_name=connector_name, connector_name=connector_name,
connector_type="NOTION_CONNECTOR", 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( async def test_indexing_progress_reports_stage_and_percent(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Progress updates surface the stage message and compute a percent complete.""" """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( updated = await NotificationService.connector_indexing.notify_indexing_progress(
session=db_session, session=db_session,
@ -93,10 +93,10 @@ async def test_indexing_progress_reports_stage_and_percent(
async def test_indexing_completed_clean_success( async def test_indexing_completed_clean_success(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""A clean multi-file sync reports ready/completed with plural wording.""" """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( done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session, session=db_session,
@ -113,10 +113,10 @@ async def test_indexing_completed_clean_success(
async def test_indexing_completed_singular_file( async def test_indexing_completed_singular_file(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""A single synced file uses singular 'file' wording.""" """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( done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session, session=db_session,
@ -130,10 +130,10 @@ async def test_indexing_completed_singular_file(
async def test_indexing_completed_nothing_to_sync( async def test_indexing_completed_nothing_to_sync(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""Completing with nothing new reports 'Already up to date!'.""" """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( done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session, session=db_session,
@ -149,10 +149,10 @@ async def test_indexing_completed_nothing_to_sync(
async def test_indexing_completed_hard_failure( async def test_indexing_completed_hard_failure(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""An error with nothing synced reports a hard failure.""" """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( done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session, session=db_session,
@ -170,10 +170,10 @@ async def test_indexing_completed_hard_failure(
async def test_indexing_completed_partial_with_error_note( async def test_indexing_completed_partial_with_error_note(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""An error after partial progress still completes, with an appended note.""" """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( done = await NotificationService.connector_indexing.notify_indexing_completed(
session=db_session, 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( async def test_retry_progress_frames_delay_as_providers(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""A retry message frames the delay as the provider's, using its short name.""" """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( retry = await NotificationService.connector_indexing.notify_retry_progress(
session=db_session, 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( async def test_retry_progress_shows_wait_and_synced_count(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
"""A retry surfaces the wait time and how many items synced so far.""" """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( retry = await NotificationService.connector_indexing.notify_retry_progress(
session=db_session, session=db_session,

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -13,22 +13,22 @@ pytestmark = pytest.mark.integration
handler = NotificationService.document_processing 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.""" """Open a document-processing notification to update in the tests below."""
return await handler.notify_processing_started( return await handler.notify_processing_started(
session=db_session, session=db_session,
user_id=db_user.id, user_id=db_user.id,
document_type="FILE", document_type="FILE",
document_name=name, document_name=name,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
async def test_processing_started_queues( 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.""" """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.type == "document_processing"
assert notification.title == "Processing: report.pdf" assert notification.title == "Processing: report.pdf"
@ -37,10 +37,10 @@ async def test_processing_started_queues(
async def test_processing_progress_maps_stage( 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.""" """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( updated = await handler.notify_processing_progress(
session=db_session, notification=notification, stage="parsing" session=db_session, notification=notification, stage="parsing"
@ -51,10 +51,10 @@ async def test_processing_progress_maps_stage(
async def test_processing_completed_success( 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.""" """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( done = await handler.notify_processing_completed(
session=db_session, notification=notification, document_id=99 session=db_session, notification=notification, document_id=99
@ -66,10 +66,10 @@ async def test_processing_completed_success(
async def test_processing_completed_failure( 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.""" """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( done = await handler.notify_processing_completed(
session=db_session, notification=notification, error_message="bad file" 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( 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.""" """A long filename is truncated in the title but kept in metadata."""
long_name = "a" * 250 long_name = "a" * 250
@ -91,7 +91,7 @@ async def test_processing_started_truncates_long_filename(
user_id=db_user.id, user_id=db_user.id,
document_type="FILE", document_type="FILE",
document_name=long_name, document_name=long_name,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
assert len(notification.title) <= 200 assert len(notification.title) <= 200

View file

@ -28,14 +28,14 @@ async def _seed(
title: str = "Title", title: str = "Title",
message: str = "Message", message: str = "Message",
read: bool = False, read: bool = False,
search_space_id: int | None = None, workspace_id: int | None = None,
metadata: dict | None = None, metadata: dict | None = None,
created_at: datetime | None = None, created_at: datetime | None = None,
) -> Notification: ) -> Notification:
"""Insert a notification row directly for the API tests to read back.""" """Insert a notification row directly for the API tests to read back."""
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
search_space_id=search_space_id, workspace_id=workspace_id,
type=type, type=type,
title=title, title=title,
message=message, message=message,

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -14,7 +14,7 @@ handler = NotificationService.insufficient_credits
async def test_insufficient_credits_message_and_action( 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.""" """An insufficient-credits notification states cost and carries a buy-credits link."""
notification = await handler.notify_insufficient_credits( notification = await handler.notify_insufficient_credits(
@ -22,7 +22,7 @@ async def test_insufficient_credits_message_and_action(
user_id=db_user.id, user_id=db_user.id,
document_name="short.pdf", document_name="short.pdf",
document_type="FILE", document_type="FILE",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
balance_micros=250_000, balance_micros=250_000,
required_micros=1_000_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["status"] == "failed"
assert notification.notification_metadata["action_label"] == "Buy credits" assert notification.notification_metadata["action_label"] == "Buy credits"
assert notification.notification_metadata["action_url"] == ( 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( 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.""" """A long document name is truncated in the notification title."""
long_name = "a" * 50 long_name = "a" * 50
@ -51,7 +51,7 @@ async def test_insufficient_credits_truncates_long_name(
user_id=db_user.id, user_id=db_user.id,
document_name=long_name, document_name=long_name,
document_type="FILE", document_type="FILE",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
balance_micros=250_000, balance_micros=250_000,
required_micros=1_000_000, required_micros=1_000_000,
) )

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import Workspace, User
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -13,7 +13,7 @@ pytestmark = pytest.mark.integration
handler = NotificationService.mention 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.""" """Raise an @mention notification for the assertions in the tests below."""
return await handler.notify_new_mention( return await handler.notify_new_mention(
session=db_session, 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_avatar_url=None,
author_email="alice@surfsense.net", author_email="alice@surfsense.net",
content_preview=preview, content_preview=preview,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
) )
async def test_new_mention_title_and_message( 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.""" """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.type == "new_mention"
assert notification.title == "Alice mentioned you" assert notification.title == "Alice mentioned you"
@ -44,21 +44,21 @@ async def test_new_mention_title_and_message(
async def test_new_mention_truncates_long_preview( 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.""" """A long comment preview is truncated in the mention message."""
notification = await _notify( notification = await _notify(
db_session, db_user, db_search_space, preview="x" * 150 db_session, db_user, db_workspace, preview="x" * 150
) )
assert notification.message == "x" * 100 + "..." assert notification.message == "x" * 100 + "..."
async def test_new_mention_is_idempotent( 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.""" """Re-notifying the same mention id reuses the existing notification row."""
first = 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_search_space, mention_id=7) second = await _notify(db_session, db_user, db_workspace, mention_id=7)
assert second.id == first.id assert second.id == first.id

View file

@ -26,7 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.app import app, limiter from app.app import app, limiter
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.config import config as app_config from app.config import config as app_config
from app.db import SearchSpace, User, get_async_session from app.db import Workspace, User, get_async_session
from app.podcasts.persistence import Podcast, PodcastStatus from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import ( from app.podcasts.schemas import (
DurationTarget, DurationTarget,
@ -39,7 +39,7 @@ from app.podcasts.schemas import (
) )
from app.podcasts.service import PodcastService from app.podcasts.service import PodcastService
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech 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 from app.users import get_auth_context
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -248,14 +248,14 @@ def make_podcast(db_session: AsyncSession):
async def _make( async def _make(
*, *,
search_space_id: int, workspace_id: int,
status: PodcastStatus = PodcastStatus.AWAITING_BRIEF, status: PodcastStatus = PodcastStatus.AWAITING_BRIEF,
title: str = "Test Podcast", title: str = "Test Podcast",
thread_id: int | None = None, thread_id: int | None = None,
) -> Podcast: ) -> Podcast:
service = PodcastService(db_session) service = PodcastService(db_session)
podcast = await service.create( 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: if status is PodcastStatus.PENDING:
await db_session.flush() await db_session.flush()
@ -298,7 +298,7 @@ def act_as():
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_other_user(db_session: AsyncSession) -> User: 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( user = User(
id=uuid.uuid4(), id=uuid.uuid4(),
email="stranger@surfsense.net", email="stranger@surfsense.net",
@ -317,9 +317,9 @@ async def foreign_podcast(
db_session: AsyncSession, db_other_user: User, make_podcast db_session: AsyncSession, db_other_user: User, make_podcast
) -> Podcast: ) -> Podcast:
"""A podcast in a space owned by the other user, invisible to db_user.""" """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) db_session.add(space)
await db_session.flush() await db_session.flush()
await create_default_roles_and_membership(db_session, space.id, db_other_user.id) await create_default_roles_and_membership(db_session, space.id, db_other_user.id)
await db_session.flush() 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")

View file

@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts" 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( resp = await client.post(
BASE, BASE,
json={ json={
"title": "Episode", "title": "Episode",
"search_space_id": search_space_id, "workspace_id": workspace_id,
"source_content": "Source content.", "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( 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") resp = await client.post(f"{BASE}/{podcast['id']}/brief/approve")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "drafting" 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 == [] assert captured_tasks.render == []
async def test_update_spec_bumps_version_and_persists(client, db_search_space): async def test_update_spec_bumps_version_and_persists(client, db_workspace):
podcast = await _create(client, db_search_space.id) podcast = await _create(client, db_workspace.id)
spec = podcast["spec"] spec = podcast["spec"]
spec["focus"] = "A sharper angle" 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" assert body["status"] == "awaiting_brief"
async def test_update_spec_with_stale_version_conflicts(client, db_search_space): async def test_update_spec_with_stale_version_conflicts(client, db_workspace):
podcast = await _create(client, db_search_space.id) podcast = await _create(client, db_workspace.id)
resp = await client.patch( resp = await client.patch(
f"{BASE}/{podcast['id']}/spec", 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 assert resp.status_code == 409
async def test_update_spec_after_approval_is_rejected(client, db_search_space): async def test_update_spec_after_approval_is_rejected(client, db_workspace):
podcast = await _create(client, db_search_space.id) podcast = await _create(client, db_workspace.id)
await client.post(f"{BASE}/{podcast['id']}/brief/approve") await client.post(f"{BASE}/{podcast['id']}/brief/approve")
resp = await client.patch( resp = await client.patch(

View file

@ -15,9 +15,9 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts" 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( 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") 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( async def test_cancel_from_a_terminal_state_conflicts(
client, db_search_space, make_podcast client, db_workspace, make_podcast
): ):
podcast = await 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") resp = await client.post(f"{BASE}/{podcast.id}/cancel")
@ -39,12 +39,12 @@ async def test_cancel_from_a_terminal_state_conflicts(
async def test_cancel_of_a_regeneration_is_rejected( async def test_cancel_of_a_regeneration_is_rejected(
client, db_search_space, make_podcast client, db_workspace, make_podcast
): ):
# Cancelling here would destroy a playable episode; reverting the # Cancelling here would destroy a playable episode; reverting the
# regeneration is the way back. # regeneration is the way back.
podcast = await make_podcast( 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}/transcript/regenerate")

View file

@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts" 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( resp = await client.post(
BASE, BASE,
json={ json={
"title": "My Episode", "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.", "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 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( resp = await client.post(
BASE, BASE,
json={ json={
"title": "Solo", "title": "Solo",
"search_space_id": db_search_space.id, "workspace_id": db_workspace.id,
"source_content": "Content.", "source_content": "Content.",
"speaker_count": 3, "speaker_count": 3,
}, },

View file

@ -33,22 +33,22 @@ pytestmark = pytest.mark.integration
def _wire_billing(monkeypatch, *, billable_call, transcript=None) -> None: def _wire_billing(monkeypatch, *, billable_call, transcript=None) -> None:
"""Replace the billing + LLM externals the draft body reaches for.""" """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" return uuid4(), "free", "openrouter/model"
async def _ainvoke(_state, config=None): async def _ainvoke(_state, config=None):
return {"transcript": transcript} 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, "billable_call", billable_call)
monkeypatch.setattr(draft, "transcript_graph", SimpleNamespace(ainvoke=_ainvoke)) monkeypatch.setattr(draft, "transcript_graph", SimpleNamespace(ainvoke=_ainvoke))
async def test_successful_draft_stores_transcript_and_starts_rendering( 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( podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
) )
@asynccontextmanager @asynccontextmanager
@ -57,7 +57,7 @@ async def test_successful_draft_stores_transcript_and_starts_rendering(
_wire_billing(monkeypatch, billable_call=_ok, transcript=build_transcript()) _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 result["status"] == "rendering"
assert podcast.status == PodcastStatus.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( 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( podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
) )
@asynccontextmanager @asynccontextmanager
@ -83,7 +83,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
_wire_billing(monkeypatch, billable_call=_deny) _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 result["reason"] == "quota"
assert podcast.status == PodcastStatus.FAILED 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( 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( podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
) )
@asynccontextmanager @asynccontextmanager
@ -110,7 +110,7 @@ async def test_billing_settlement_failure_fails_the_podcast(
monkeypatch, billable_call=_settlement_fails, transcript=build_transcript() 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 result["reason"] == "billing"
assert podcast.status == PodcastStatus.FAILED assert podcast.status == PodcastStatus.FAILED

View file

@ -12,9 +12,9 @@ from app.db import NewChatThread, PublicChatSnapshot, User
pytestmark = pytest.mark.integration 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( 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) db_session.add(thread)
await db_session.flush() 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( 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( await _snapshot(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user=db_user, user=db_user,
token="tok-audio", token="tok-audio",
podcasts=[{"original_id": 555, "storage_key": "podcasts/x.mp3"}], 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( 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( await _snapshot(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user=db_user, user=db_user,
token="tok-gone", token="tok-gone",
podcasts=[{"original_id": 556, "storage_key": "podcasts/gone.mp3"}], 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( 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( await _snapshot(
db_session, db_session,
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
user=db_user, user=db_user,
token="tok-empty", token="tok-empty",
podcasts=[], podcasts=[],

View file

@ -24,10 +24,10 @@ BASE = "/api/v1/podcasts"
async def test_regenerate_from_ready_reopens_the_brief_gate( 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( 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") 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( 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( 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}/transcript/regenerate")
@ -54,15 +54,15 @@ async def test_approving_the_reopened_brief_starts_a_fresh_draft(
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "drafting" 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( 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. # Nothing has been drafted yet, so there is nothing to regenerate.
podcast = await 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}/transcript/regenerate") 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( 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( 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") 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( 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( 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}/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( 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 # Changing one's mind is allowed even after the reopened brief was
# approved: the episode survives until a new render replaces it. # approved: the episode survives until a new render replaces it.
podcast = await make_podcast( 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}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/brief/approve") 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( 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( 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) service = PodcastService(db_session)
await service.regenerate(podcast) 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( 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 # Reverting must not strand the episode: the user can change their mind
# again immediately. # again immediately.
podcast = await make_podcast( 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}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/regenerate/revert") 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( 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. # A first-time brief has no regeneration to revert.
podcast = await 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}/regenerate/revert") 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( 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( 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") 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( 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 # Legacy episodes finished before briefs existed; reopening a gate with
# nothing to review would strand them there. # nothing to review would strand them there.
podcast = Podcast( podcast = Podcast(
title="Legacy Episode", title="Legacy Episode",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
status=PodcastStatus.READY, status=PodcastStatus.READY,
spec_version=1, spec_version=1,
file_location="/var/old/podcast.mp3", file_location="/var/old/podcast.mp3",

View file

@ -20,10 +20,10 @@ pytestmark = pytest.mark.integration
async def test_render_marks_ready_and_stores_audio( 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( 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) 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( async def test_rerender_replaces_audio_and_purges_the_old_object(
db_session, db_session,
db_search_space, db_workspace,
make_podcast, make_podcast,
bind_task_session, bind_task_session,
fake_tts, 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 # A regenerated episode keeps exactly one stored object: the new render
# must not leak the superseded audio in the object store. # must not leak the superseded audio in the object store.
podcast = await make_podcast( 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 old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio" 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( async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
db_session, db_session,
db_search_space, db_workspace,
make_podcast, make_podcast,
bind_task_session, bind_task_session,
fake_tts, 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 # stale render must neither resurrect the redo nor leak the object it
# already stored. # already stored.
podcast = await make_podcast( 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 old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio" fake_storage.objects[old_key] = b"old-audio"

View file

@ -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 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 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( 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) act_as(db_other_user)
resp = await client.get(f"{BASE}/{podcast.id}") 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( 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) act_as(db_other_user)
@ -32,7 +32,7 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
BASE, BASE,
json={ json={
"title": "X", "title": "X",
"search_space_id": db_search_space.id, "workspace_id": db_workspace.id,
"source_content": "content", "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( 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) resp = await client.get(BASE)

View file

@ -16,10 +16,10 @@ BASE = "/api/v1/podcasts"
async def test_stream_serves_stored_audio( 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( 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" 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" 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( 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") 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( 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( 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") resp = await client.get(f"{BASE}/{podcast.id}/stream")

View file

@ -17,10 +17,10 @@ pytestmark = pytest.mark.integration
async def test_marking_failed_records_the_reason_on_a_running_podcast( 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( 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") 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( 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( 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") await runtime.mark_failed(podcast.id, "too late")

View file

@ -9,7 +9,7 @@ import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config as app_config from app.config import config as app_config
from app.db import Chunk, Document, DocumentType, SearchSpace, User from app.db import Chunk, Document, DocumentType, Workspace, User
EMBEDDING_DIM = app_config.embedding_model_instance.dimension EMBEDDING_DIM = app_config.embedding_model_instance.dimension
DUMMY_EMBEDDING = [0.1] * EMBEDDING_DIM DUMMY_EMBEDDING = [0.1] * EMBEDDING_DIM
@ -20,7 +20,7 @@ def _make_document(
title: str, title: str,
document_type: DocumentType, document_type: DocumentType,
content: str, content: str,
search_space_id: int, workspace_id: int,
created_by_id: str, created_by_id: str,
updated_at: datetime | None = None, updated_at: datetime | None = None,
) -> Document: ) -> Document:
@ -32,7 +32,7 @@ def _make_document(
content_hash=f"content-{uid}", content_hash=f"content-{uid}",
unique_identifier_hash=f"uid-{uid}", unique_identifier_hash=f"uid-{uid}",
source_markdown=content, source_markdown=content,
search_space_id=search_space_id, workspace_id=workspace_id,
created_by_id=created_by_id, created_by_id=created_by_id,
embedding=DUMMY_EMBEDDING, embedding=DUMMY_EMBEDDING,
updated_at=updated_at or datetime.now(UTC), updated_at=updated_at or datetime.now(UTC),
@ -50,29 +50,29 @@ def _make_chunk(*, content: str, document_id: int) -> Chunk:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seed_large_doc( 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). """Insert a document with 35 chunks (more than _MAX_FETCH_CHUNKS_PER_DOC=20).
Also inserts a small 3-chunk document for diversity testing. 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). and ``large_chunk_ids`` (all 35 chunk IDs).
""" """
user_id = str(db_user.id) user_id = str(db_user.id)
space_id = db_search_space.id space_id = db_workspace.id
large_doc = _make_document( large_doc = _make_document(
title="Large PDF Document", title="Large PDF Document",
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content="large document about quarterly performance reviews and budgets", content="large document about quarterly performance reviews and budgets",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
small_doc = _make_document( small_doc = _make_document(
title="Small Note", title="Small Note",
document_type=DocumentType.NOTE, document_type=DocumentType.NOTE,
content="quarterly performance review summary note", content="quarterly performance review summary note",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
) )
@ -102,25 +102,25 @@ async def seed_large_doc(
"small_doc": small_doc, "small_doc": small_doc,
"large_chunk_ids": [c.id for c in large_chunks], "large_chunk_ids": [c.id for c in large_chunks],
"small_chunk_ids": [c.id for c in small_chunks], "small_chunk_ids": [c.id for c in small_chunks],
"search_space": db_search_space, "workspace": db_workspace,
"user": db_user, "user": db_user,
} }
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seed_date_filtered_docs( 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.""" """Insert matching docs with different timestamps for date-filter tests."""
user_id = str(db_user.id) user_id = str(db_user.id)
space_id = db_search_space.id space_id = db_workspace.id
now = datetime.now(UTC) now = datetime.now(UTC)
recent_doc = _make_document( recent_doc = _make_document(
title="Recent OCV Notes", title="Recent OCV Notes",
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content="ocv meeting decisions and action items", content="ocv meeting decisions and action items",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
updated_at=now, updated_at=now,
) )
@ -128,7 +128,7 @@ async def seed_date_filtered_docs(
title="Old OCV Notes", title="Old OCV Notes",
document_type=DocumentType.FILE, document_type=DocumentType.FILE,
content="ocv meeting decisions and action items", content="ocv meeting decisions and action items",
search_space_id=space_id, workspace_id=space_id,
created_by_id=user_id, created_by_id=user_id,
updated_at=now - timedelta(days=730), updated_at=now - timedelta(days=730),
) )
@ -153,6 +153,6 @@ async def seed_date_filtered_docs(
return { return {
"recent_doc": recent_doc, "recent_doc": recent_doc,
"old_doc": old_doc, "old_doc": old_doc,
"search_space": db_search_space, "workspace": db_workspace,
"user": db_user, "user": db_user,
} }

View file

@ -18,13 +18,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc): 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.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
"""Document metadata (title, type, etc.) should be present even without joinedload.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): 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.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID (original order).""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): async def test_score_is_positive_float(db_session, seed_large_doc):
"""Each result should have a positive float score from RRF.""" """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) retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, query_embedding=DUMMY_EMBEDDING,
) )

View file

@ -17,13 +17,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc): 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.""" """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) retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): async def test_doc_metadata_populated(db_session, seed_large_doc):
"""Document metadata should be present from the RRF results.""" """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) retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, 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): async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID.""" """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) retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search( results = await retriever.hybrid_search(
query_text="quarterly performance review", query_text="quarterly performance review",
top_k=10, top_k=10,
search_space_id=space_id, workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING, query_embedding=DUMMY_EMBEDDING,
) )

View file

@ -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 ``POST /search-source-connectors/{connector_id}/index?workspace_id=<X>`` must
authorize against the **connector's own** ``search_space_id`` (matching the authorize against the **connector's own** ``workspace_id`` (matching the
read/update/delete handlers), not the caller-supplied ``search_space_id`` query read/update/delete handlers), not the caller-supplied ``workspace_id`` query
parameter, and must reject a connector that does not belong to the requested 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 Without this, a user who owns workspace B could index another user's
connector (which lives in space A) by passing ``search_space_id=B``: the connector (which lives in space A) by passing ``workspace_id=B``: the
background indexer would run with the **victim connector's stored credentials** 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 and write the fetched content into the attacker's space. These tests pin that
boundary. boundary.
@ -27,11 +27,11 @@ from app.auth.context import AuthContext
from app.db import ( from app.db import (
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
SearchSpace, Workspace,
User, User,
) )
from app.routes.search_source_connectors_routes import index_connector_content 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 pytestmark = pytest.mark.integration
@ -39,9 +39,9 @@ pytestmark = pytest.mark.integration
_CHECK_PERMISSION = "app.routes.search_source_connectors_routes.check_permission" _CHECK_PERMISSION = "app.routes.search_source_connectors_routes.check_permission"
async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpace]: async def _make_user_with_space(session: AsyncSession) -> tuple[User, Workspace]:
"""A user plus a search space they own, with the default roles/membership """A user plus a workspace they own, with the default roles/membership
the ``POST /searchspaces`` route would create (so ``check_permission`` would the ``POST /workspaces`` route would create (so ``check_permission`` would
legitimately pass for this user on this space).""" legitimately pass for this user on this space)."""
user = User( user = User(
id=uuid.uuid4(), id=uuid.uuid4(),
@ -53,7 +53,7 @@ async def _make_user_with_space(session: AsyncSession) -> tuple[User, SearchSpac
) )
session.add(user) session.add(user)
await session.flush() 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) session.add(space)
await session.flush() await session.flush()
await create_default_roles_and_membership(session, space.id, user.id) 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( async def _make_connector(
session: AsyncSession, session: AsyncSession,
owner: User, owner: User,
space: SearchSpace, space: Workspace,
connector_type: SearchSourceConnectorType, connector_type: SearchSourceConnectorType,
) -> SearchSourceConnector: ) -> SearchSourceConnector:
connector = SearchSourceConnector( connector = SearchSourceConnector(
@ -77,7 +77,7 @@ async def _make_connector(
"repo_full_names": ["octocat/Hello-World"], "repo_full_names": ["octocat/Hello-World"],
}, },
is_indexable=True, is_indexable=True,
search_space_id=space.id, workspace_id=space.id,
user_id=owner.id, user_id=owner.id,
) )
session.add(connector) session.add(connector)
@ -90,7 +90,7 @@ class TestConnectorIndexCrossSpaceAuthz:
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""Attacker (owns space B) cannot index victim's connector (in space A) """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 The mismatch is rejected with 404 **before** ``check_permission`` runs
which is essential, because that permission check *would* pass: the which is essential, because that permission check *would* pass: the
@ -108,13 +108,13 @@ class TestConnectorIndexCrossSpaceAuthz:
): ):
await index_connector_content( await index_connector_content(
connector_id=connector_a.id, 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, session=db_session,
auth=AuthContext.session(attacker), auth=AuthContext.session(attacker),
) )
assert exc_info.value.status_code == 404 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. # on) the permission check — which would have passed for space B.
check_permission_mock.assert_not_awaited() check_permission_mock.assert_not_awaited()
@ -122,7 +122,7 @@ class TestConnectorIndexCrossSpaceAuthz:
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""A legitimate same-space index passes the reconciliation and authorizes """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).""" client-supplied query param)."""
owner, space = await _make_user_with_space(db_session) owner, space = await _make_user_with_space(db_session)
# A "live" connector type returns early (no Celery dispatch) right after # A "live" connector type returns early (no Celery dispatch) right after
@ -139,11 +139,11 @@ class TestConnectorIndexCrossSpaceAuthz:
): ):
await index_connector_content( await index_connector_content(
connector_id=connector.id, 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, session=db_session,
auth=AuthContext.session(owner), auth=AuthContext.session(owner),
) )
check_permission_mock.assert_awaited_once() check_permission_mock.assert_awaited_once()
# The space passed to check_permission must be the connector's own space. # 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

View file

@ -7,14 +7,14 @@ import pytest_asyncio
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, DocumentType, DocumentVersion, SearchSpace, User from app.db import Document, DocumentType, DocumentVersion, Workspace, User
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_document( async def db_document(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> Document: ) -> Document:
doc = Document( doc = Document(
title="Test Doc", title="Test Doc",
@ -24,7 +24,7 @@ async def db_document(
content_hash="abc123", content_hash="abc123",
unique_identifier_hash="local_folder:test-folder:test.md", unique_identifier_hash="local_folder:test-folder:test.md",
source_markdown="# Test\n\nOriginal content.", source_markdown="# Test\n\nOriginal content.",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
created_by_id=db_user.id, created_by_id=db_user.id,
) )
db_session.add(doc) db_session.add(doc)

View file

@ -32,7 +32,7 @@ from app.auth.context import AuthContext
from app.db import ( from app.db import (
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
SearchSpace, Workspace,
User, User,
) )
from app.routes.obsidian_plugin_routes import ( from app.routes.obsidian_plugin_routes import (
@ -43,7 +43,7 @@ from app.routes.obsidian_plugin_routes import (
obsidian_stats, obsidian_stats,
obsidian_sync, 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 ( from app.schemas.obsidian_plugin import (
ConnectRequest, ConnectRequest,
DeleteAck, DeleteAck,
@ -90,7 +90,7 @@ def _make_note_payload(vault_id: str, path: str, content_hash: str) -> NotePaylo
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def race_user_and_space(async_engine): 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. concurrent /connect sessions in the race test can both see them.
We can't use the savepoint-trapped ``db_session`` fixture here 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_superuser=False,
is_verified=True, 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]) setup.add_all([user, space])
await setup.flush() await setup.flush()
await create_default_roles_and_membership(setup, space.id, user_id) await create_default_roles_and_membership(setup, space.id, user_id)
@ -167,7 +167,7 @@ class TestConnectRace:
payload = ConnectRequest( payload = ConnectRequest(
vault_id=vault_id, vault_id=vault_id,
vault_name=f"My Vault {name_suffix}", vault_name=f"My Vault {name_suffix}",
search_space_id=space_id, workspace_id=space_id,
vault_fingerprint=fingerprint, vault_fingerprint=fingerprint,
) )
await obsidian_connect(payload, auth=_auth(fresh_user), session=s) await obsidian_connect(payload, auth=_auth(fresh_user), session=s)
@ -207,7 +207,7 @@ class TestConnectRace:
"vault_fingerprint": "fp-1", "vault_fingerprint": "fp-1",
}, },
user_id=user_id, user_id=user_id,
search_space_id=space_id, workspace_id=space_id,
) )
) )
await s.commit() await s.commit()
@ -226,7 +226,7 @@ class TestConnectRace:
"vault_fingerprint": "fp-2", "vault_fingerprint": "fp-2",
}, },
user_id=user_id, user_id=user_id,
search_space_id=space_id, workspace_id=space_id,
) )
) )
await s.commit() await s.commit()
@ -252,7 +252,7 @@ class TestConnectRace:
"vault_fingerprint": fingerprint, "vault_fingerprint": fingerprint,
}, },
user_id=user_id, user_id=user_id,
search_space_id=space_id, workspace_id=space_id,
) )
) )
await s.commit() await s.commit()
@ -271,7 +271,7 @@ class TestConnectRace:
"vault_fingerprint": fingerprint, "vault_fingerprint": fingerprint,
}, },
user_id=user_id, user_id=user_id,
search_space_id=space_id, workspace_id=space_id,
) )
) )
await s.commit() await s.commit()
@ -294,7 +294,7 @@ class TestConnectRace:
ConnectRequest( ConnectRequest(
vault_id=vault_id_a, vault_id=vault_id_a,
vault_name="Shared Vault", vault_name="Shared Vault",
search_space_id=space_id, workspace_id=space_id,
vault_fingerprint=fingerprint, vault_fingerprint=fingerprint,
), ),
auth=_auth(fresh_user), auth=_auth(fresh_user),
@ -307,7 +307,7 @@ class TestConnectRace:
ConnectRequest( ConnectRequest(
vault_id=vault_id_b, vault_id=vault_id_b,
vault_name="Shared Vault", vault_name="Shared Vault",
search_space_id=space_id, workspace_id=space_id,
vault_fingerprint=fingerprint, vault_fingerprint=fingerprint,
), ),
auth=_auth(fresh_user), auth=_auth(fresh_user),
@ -341,7 +341,7 @@ class TestWireContractSmoke:
field renames the way the TypeScript decoder would catch them.""" field renames the way the TypeScript decoder would catch them."""
async def test_full_flow_returns_typed_payloads( 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()) vault_id = str(uuid.uuid4())
@ -350,7 +350,7 @@ class TestWireContractSmoke:
ConnectRequest( ConnectRequest(
vault_id=vault_id, vault_id=vault_id,
vault_name="Smoke Vault", vault_name="Smoke Vault",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex, vault_fingerprint="fp-" + uuid.uuid4().hex,
), ),
auth=_auth(db_user), auth=_auth(db_user),
@ -488,14 +488,14 @@ class TestWireContractSmoke:
assert stats_resp.last_sync_at is None assert stats_resp.last_sync_at is None
async def test_sync_queues_binary_attachments( 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()) vault_id = str(uuid.uuid4())
await obsidian_connect( await obsidian_connect(
ConnectRequest( ConnectRequest(
vault_id=vault_id, vault_id=vault_id,
vault_name="Queue Vault", vault_name="Queue Vault",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex, vault_fingerprint="fp-" + uuid.uuid4().hex,
), ),
auth=_auth(db_user), auth=_auth(db_user),
@ -539,14 +539,14 @@ class TestWireContractSmoke:
queue_mock.assert_called_once() queue_mock.assert_called_once()
async def test_sync_rejects_unsupported_attachment_extension( 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()) vault_id = str(uuid.uuid4())
await obsidian_connect( await obsidian_connect(
ConnectRequest( ConnectRequest(
vault_id=vault_id, vault_id=vault_id,
vault_name="Reject Vault", vault_name="Reject Vault",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex, vault_fingerprint="fp-" + uuid.uuid4().hex,
), ),
auth=_auth(db_user), auth=_auth(db_user),
@ -593,14 +593,14 @@ class TestWireContractSmoke:
queue_mock.assert_not_called() queue_mock.assert_not_called()
async def test_sync_rejects_mime_extension_mismatch( 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()) vault_id = str(uuid.uuid4())
await obsidian_connect( await obsidian_connect(
ConnectRequest( ConnectRequest(
vault_id=vault_id, vault_id=vault_id,
vault_name="Mismatch Vault", vault_name="Mismatch Vault",
search_space_id=db_search_space.id, workspace_id=db_workspace.id,
vault_fingerprint="fp-" + uuid.uuid4().hex, vault_fingerprint="fp-" + uuid.uuid4().hex,
), ),
auth=_auth(db_user), auth=_auth(db_user),

View file

@ -7,9 +7,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.db import PersonalAccessToken, SearchSpace, User from app.db import PersonalAccessToken, Workspace, User
from app.users import allow_any_principal, require_session_context 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 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( async def test_pat_is_rejected_for_api_disabled_space(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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() await db_session.flush()
auth = _pat_auth(db_user) auth = _pat_auth(db_user)
with pytest.raises(HTTPException) as exc_info: 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.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( async def test_pat_is_allowed_for_api_enabled_space(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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() await db_session.flush()
auth = _pat_auth(db_user) 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.user_id == db_user.id
assert membership.search_space_id == db_search_space.id assert membership.workspace_id == db_workspace.id

View file

@ -7,9 +7,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.db import PersonalAccessToken, SearchSpace, User from app.db import PersonalAccessToken, Workspace, User
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.utils.rbac import check_search_space_access, get_allowed_read_space_ids from app.utils.rbac import check_workspace_access, get_allowed_read_space_ids
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -30,8 +30,8 @@ async def _space_with_membership(
user: User, user: User,
*, *,
api_access_enabled: bool, api_access_enabled: bool,
) -> SearchSpace: ) -> Workspace:
space = SearchSpace( space = Workspace(
name="Zero Authz Space", name="Zero Authz Space",
user_id=user.id, user_id=user.id,
api_access_enabled=api_access_enabled, api_access_enabled=api_access_enabled,
@ -43,10 +43,10 @@ async def _space_with_membership(
return space 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_session: AsyncSession,
db_user: User, db_user: User,
db_search_space: SearchSpace, db_workspace: Workspace,
): ):
disabled_space = await _space_with_membership( disabled_space = await _space_with_membership(
db_session, 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)) allowed_ids = set(await get_allowed_read_space_ids(db_session, session_auth))
for space in (db_search_space, disabled_space): for space in (db_workspace, disabled_space):
membership = await check_search_space_access(db_session, session_auth, space.id) membership = await check_workspace_access(db_session, session_auth, space.id)
assert membership.search_space_id in allowed_ids assert membership.workspace_id in allowed_ids
async def test_zero_read_set_applies_pat_api_access_gate( async def test_zero_read_set_applies_pat_api_access_gate(
db_session: AsyncSession, db_session: AsyncSession,
db_user: User, 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( disabled_space = await _space_with_membership(
db_session, db_session,
db_user, 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)) 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 assert disabled_space.id not in allowed_ids
with pytest.raises(HTTPException) as exc_info: 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 assert exc_info.value.status_code == 403

View file

@ -94,7 +94,7 @@ def fake_session_factory():
class TestActionLogMiddlewareDisabled: class TestActionLogMiddlewareDisabled:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_op_when_flag_off(self, patch_get_flags) -> None: 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( request = _FakeRequest(
tool_call={ tool_call={
"name": "make_widget", "name": "make_widget",
@ -110,7 +110,7 @@ class TestActionLogMiddlewareDisabled:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_op_when_thread_id_none(self, patch_get_flags) -> None: 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( request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"} tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
) )
@ -126,7 +126,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags, fake_session_factory self, patch_get_flags, fake_session_factory
) -> None: ) -> None:
captured, factory = fake_session_factory 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( request = _FakeRequest(
tool_call={ tool_call={
"name": "make_widget", "name": "make_widget",
@ -150,7 +150,7 @@ class TestActionLogMiddlewarePersistence:
assert len(captured["rows"]) == 1 assert len(captured["rows"]) == 1
row = captured["rows"][0] row = captured["rows"][0]
assert row.thread_id == 42 assert row.thread_id == 42
assert row.search_space_id == 7 assert row.workspace_id == 7
assert row.user_id == "u1" assert row.user_id == "u1"
assert row.tool_name == "make_widget" assert row.tool_name == "make_widget"
assert row.args == {"color": "red", "size": 3} assert row.args == {"color": "red", "size": 3}
@ -170,7 +170,7 @@ class TestActionLogMiddlewarePersistence:
) -> None: ) -> None:
"""``chat_turn_id`` falls back to NULL when ``runtime.config`` is absent.""" """``chat_turn_id`` falls back to NULL when ``runtime.config`` is absent."""
captured, factory = fake_session_factory 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( request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc-1"}, tool_call={"name": "make_widget", "args": {}, "id": "tc-1"},
runtime=None, runtime=None,
@ -190,7 +190,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags, fake_session_factory self, patch_get_flags, fake_session_factory
) -> None: ) -> None:
captured, factory = fake_session_factory 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( request = _FakeRequest(
tool_call={"name": "make_widget", "args": {"color": "red"}, "id": "tc1"} tool_call={"name": "make_widget", "args": {"color": "red"}, "id": "tc1"}
) )
@ -214,7 +214,7 @@ class TestActionLogMiddlewarePersistence:
self, patch_get_flags self, patch_get_flags
) -> None: ) -> None:
"""Even if the DB write blows up, the tool's result must reach the model.""" """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( request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"} tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
) )
@ -250,7 +250,7 @@ class TestReverseDescriptor:
) )
mw = ActionLogMiddleware( mw = ActionLogMiddleware(
thread_id=1, thread_id=1,
search_space_id=1, workspace_id=1,
user_id="u", user_id="u",
tool_definitions={"make_widget": tool_def}, tool_definitions={"make_widget": tool_def},
) )
@ -296,7 +296,7 @@ class TestReverseDescriptor:
) )
mw = ActionLogMiddleware( mw = ActionLogMiddleware(
thread_id=1, thread_id=1,
search_space_id=1, workspace_id=1,
user_id=None, user_id=None,
tool_definitions={"make_widget": tool_def}, tool_definitions={"make_widget": tool_def},
) )
@ -321,7 +321,7 @@ class TestReverseDescriptor:
self, patch_get_flags, fake_session_factory self, patch_get_flags, fake_session_factory
) -> None: ) -> None:
captured, factory = fake_session_factory 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( request = _FakeRequest(
tool_call={"name": "unknown_tool", "args": {}, "id": "tc1"} tool_call={"name": "unknown_tool", "args": {}, "id": "tc1"}
) )
@ -343,7 +343,7 @@ class TestActionLogDispatch:
self, patch_get_flags, fake_session_factory self, patch_get_flags, fake_session_factory
) -> None: ) -> None:
_captured, factory = fake_session_factory _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( request = _FakeRequest(
tool_call={ tool_call={
"name": "make_widget", "name": "make_widget",
@ -383,7 +383,7 @@ class TestActionLogDispatch:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_dispatch_when_persistence_fails(self, patch_get_flags) -> None: async def test_no_dispatch_when_persistence_fails(self, patch_get_flags) -> None:
"""If commit fails the dispatch is suppressed (no row to surface).""" """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( request = _FakeRequest(
tool_call={"name": "make_widget", "args": {}, "id": "tc1"} tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
) )
@ -411,7 +411,7 @@ class TestArgsTruncation:
self, patch_get_flags, fake_session_factory self, patch_get_flags, fake_session_factory
) -> None: ) -> None:
captured, factory = fake_session_factory 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. # Build a > 32KB string so the persisted payload triggers the truncation path.
huge = "x" * (40 * 1024) huge = "x" * (40 * 1024)
request = _FakeRequest( request = _FakeRequest(

View file

@ -99,7 +99,7 @@ class TestResolveMentions:
session.execute = AsyncMock() session.execute = AsyncMock()
result = await resolve_mentions( result = await resolve_mentions(
session, session,
search_space_id=1, workspace_id=1,
mentioned_documents=None, mentioned_documents=None,
) )
assert isinstance(result, ResolvedMentionSet) assert isinstance(result, ResolvedMentionSet)
@ -134,7 +134,7 @@ class TestResolveMentions:
out = await resolve_mentions( out = await resolve_mentions(
session, session,
search_space_id=5, workspace_id=5,
mentioned_documents=[chip], mentioned_documents=[chip],
) )
assert len(out.mentions) == 1 assert len(out.mentions) == 1
@ -170,7 +170,7 @@ class TestResolveMentions:
out = await resolve_mentions( out = await resolve_mentions(
session, session,
search_space_id=3, workspace_id=3,
mentioned_documents=[chip], mentioned_documents=[chip],
) )
assert len(out.mentions) == 1 assert len(out.mentions) == 1
@ -201,7 +201,7 @@ class TestResolveMentions:
out = await resolve_mentions( out = await resolve_mentions(
session, session,
search_space_id=1, workspace_id=1,
mentioned_documents=[chip], mentioned_documents=[chip],
) )
assert out.mentions == [] assert out.mentions == []
@ -238,7 +238,7 @@ class TestResolveMentions:
out = await resolve_mentions( out = await resolve_mentions(
session, session,
search_space_id=1, workspace_id=1,
mentioned_documents=[chip_short, chip_long], mentioned_documents=[chip_short, chip_long],
) )
tokens = [tok for tok, _ in out.token_to_path] tokens = [tok for tok, _ in out.token_to_path]
@ -265,7 +265,7 @@ class TestResolveMentions:
out = await resolve_mentions( out = await resolve_mentions(
session, session,
search_space_id=2, workspace_id=2,
mentioned_documents=None, mentioned_documents=None,
mentioned_document_ids=[7], mentioned_document_ids=[7],
) )

View file

@ -149,7 +149,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc( document = await virtual_path_to_doc(
session, session,
search_space_id=5, workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/{encoded_basename}", virtual_path=f"{DOCUMENTS_ROOT}/{encoded_basename}",
) )
assert document is target_doc assert document is target_doc
@ -169,7 +169,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc( document = await virtual_path_to_doc(
session, session,
search_space_id=5, workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/Calendar_ Happy birthday!.xml", virtual_path=f"{DOCUMENTS_ROOT}/Calendar_ Happy birthday!.xml",
) )
assert document is None assert document is None
@ -191,7 +191,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc( document = await virtual_path_to_doc(
session, session,
search_space_id=5, workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/Plain Note.xml", virtual_path=f"{DOCUMENTS_ROOT}/Plain Note.xml",
) )
assert document is target_doc assert document is target_doc
@ -217,7 +217,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc( document = await virtual_path_to_doc(
session, session,
search_space_id=5, workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf.xml", virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf.xml",
) )
assert document is target_doc assert document is target_doc
@ -239,7 +239,7 @@ class TestVirtualPathToDoc:
document = await virtual_path_to_doc( document = await virtual_path_to_doc(
session, session,
search_space_id=5, workspace_id=5,
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf", virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf",
) )
assert document is target_doc assert document is target_doc

View file

@ -30,7 +30,7 @@ class _DummyMiddleware(AgentMiddleware):
def _ctx() -> PluginContext: def _ctx() -> PluginContext:
return PluginContext.build( return PluginContext.build(
search_space_id=1, workspace_id=1,
user_id="u", user_id="u",
thread_visibility="PRIVATE", # type: ignore[arg-type] thread_visibility="PRIVATE", # type: ignore[arg-type]
llm=MagicMock(), llm=MagicMock(),
@ -165,12 +165,12 @@ class TestPluginContext:
def test_build_includes_required_fields(self) -> None: def test_build_includes_required_fields(self) -> None:
llm = MagicMock() llm = MagicMock()
ctx = PluginContext.build( ctx = PluginContext.build(
search_space_id=42, workspace_id=42,
user_id="user-1", user_id="user-1",
thread_visibility="PRIVATE", # type: ignore[arg-type] thread_visibility="PRIVATE", # type: ignore[arg-type]
llm=llm, llm=llm,
) )
assert ctx["search_space_id"] == 42 assert ctx["workspace_id"] == 42
assert ctx["user_id"] == "user-1" assert ctx["user_id"] == "user-1"
assert ctx["llm"] is llm assert ctx["llm"] is llm

View file

@ -11,7 +11,7 @@ from app.agents.chat.multi_agent_chat.main_agent.skills.backends import (
SKILLS_BUILTIN_PREFIX, SKILLS_BUILTIN_PREFIX,
SKILLS_SPACE_PREFIX, SKILLS_SPACE_PREFIX,
BuiltinSkillsBackend, BuiltinSkillsBackend,
SearchSpaceSkillsBackend, WorkspaceSkillsBackend,
build_skills_backend_factory, build_skills_backend_factory,
default_skills_sources, default_skills_sources,
) )
@ -176,7 +176,7 @@ class _FakeKBBackend:
return out return out
class TestSearchSpaceSkillsBackend: class TestWorkspaceSkillsBackend:
def test_remaps_paths_when_listing(self) -> None: def test_remaps_paths_when_listing(self) -> None:
listing = [ listing = [
{"path": "/documents/_skills/policy", "is_dir": True}, {"path": "/documents/_skills/policy", "is_dir": True},
@ -184,7 +184,7 @@ class TestSearchSpaceSkillsBackend:
{"path": "/documents/other-folder/x.md", "is_dir": False}, {"path": "/documents/other-folder/x.md", "is_dir": False},
] ]
kb = _FakeKBBackend(listing=listing, file_contents={}) kb = _FakeKBBackend(listing=listing, file_contents={})
backend = SearchSpaceSkillsBackend(kb) backend = WorkspaceSkillsBackend(kb)
infos = asyncio.run(backend.als_info("/")) infos = asyncio.run(backend.als_info("/"))
assert kb.last_ls_path == "/documents/_skills" assert kb.last_ls_path == "/documents/_skills"
paths = [info["path"] for info in infos] paths = [info["path"] for info in infos]
@ -200,7 +200,7 @@ class TestSearchSpaceSkillsBackend:
"/documents/_skills/policy/SKILL.md": b"---\nname: policy\n---\n", "/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"])) responses = asyncio.run(backend.adownload_files(["/policy/SKILL.md"]))
assert kb.last_download_paths == ["/documents/_skills/policy/SKILL.md"] assert kb.last_download_paths == ["/documents/_skills/policy/SKILL.md"]
assert responses[0].path == "/policy/SKILL.md" assert responses[0].path == "/policy/SKILL.md"
@ -208,7 +208,7 @@ class TestSearchSpaceSkillsBackend:
assert responses[0].content is not None assert responses[0].content is not None
def test_sync_methods_raise_not_implemented(self) -> None: def test_sync_methods_raise_not_implemented(self) -> None:
backend = SearchSpaceSkillsBackend(_FakeKBBackend([], {})) backend = WorkspaceSkillsBackend(_FakeKBBackend([], {}))
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
backend.ls_info("/") backend.ls_info("/")
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
@ -221,7 +221,7 @@ class TestSearchSpaceSkillsBackend:
], ],
file_contents={}, file_contents={},
) )
backend = SearchSpaceSkillsBackend(kb, kb_root="/skills_admin") backend = WorkspaceSkillsBackend(kb, kb_root="/skills_admin")
infos = asyncio.run(backend.als_info("/")) infos = asyncio.run(backend.als_info("/"))
assert kb.last_ls_path == "/skills_admin" assert kb.last_ls_path == "/skills_admin"
assert infos[0]["path"] == "/x" assert infos[0]["path"] == "/x"

View file

@ -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, "_compile_typst", lambda _source: b"pdf")
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: 1) 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"}) result = await _invoke(tool, {"user_info": "Jane Doe experience"})
assert result["status"] == "ready" assert result["status"] == "ready"
@ -178,7 +178,7 @@ async def test_generate_resume_compresses_when_over_limit(monkeypatch) -> None:
page_counts = iter([2, 1]) page_counts = iter([2, 1])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts)) 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}) result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "ready" 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]) page_counts = iter([3, 3, 2])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts)) 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}) result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "ready" 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]) page_counts = iter([7, 6, 6])
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts)) 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}) result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
assert result["status"] == "failed" assert result["status"] == "failed"

View file

@ -1,10 +1,10 @@
"""Lock the runtime model-policy backstop in ``build_dependencies``. """Lock the runtime model-policy backstop in ``build_dependencies``.
Automations resolve their LLM from the *captured* ``chat_model_id`` snapshot (so 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 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 fails the run clearly. When no snapshot is present, resolution falls back to the
live search space. live workspace.
""" """
from __future__ import annotations from __future__ import annotations
@ -25,20 +25,20 @@ pytestmark = pytest.mark.unit
class _FakeSession: 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: def __init__(self, workspace: Any) -> None:
self._search_space = search_space self._workspace = workspace
async def get(self, _model: Any, _pk: int) -> Any: async def get(self, _model: Any, _pk: int) -> Any:
return self._search_space return self._workspace
@pytest.fixture @pytest.fixture
def patched_side_effects(monkeypatch: pytest.MonkeyPatch): def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
"""Stub the connector setup + checkpointer so only policy/LLM logic runs.""" """Stub the connector setup + checkpointer so only policy/LLM logic runs."""
async def _fake_setup(_session, *, search_space_id): async def _fake_setup(_session, *, workspace_id):
return (SimpleNamespace(name="connector"), "fc-key") return (SimpleNamespace(name="connector"), "fc-key")
monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup) monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup)
@ -48,35 +48,35 @@ def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
async def test_build_dependencies_resolves_captured_chat_model_id( async def test_build_dependencies_resolves_captured_chat_model_id(
monkeypatch: pytest.MonkeyPatch, patched_side_effects monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None: ) -> 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] = {} 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["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) return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load) monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
# Captured path validates the explicit ids; passes for this test. # Captured path validates the explicit ids; passes for this test.
monkeypatch.setattr(deps_mod, "assert_models_billable", lambda **_kw: None) 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. # snapshot is supplied.
monkeypatch.setattr( monkeypatch.setattr(
deps_mod, deps_mod,
"assert_automation_models_billable", "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( result = await build_dependencies(
session=_FakeSession(search_space), session=_FakeSession(workspace),
search_space_id=42, workspace_id=42,
chat_model_id=-7, chat_model_id=-7,
image_gen_model_id=5, image_gen_model_id=5,
vision_model_id=-1, 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.llm.name == "llm"
assert result.firecrawl_api_key == "fc-key" assert result.firecrawl_api_key == "fc-key"
@ -84,7 +84,7 @@ async def test_build_dependencies_resolves_captured_chat_model_id(
async def test_build_dependencies_validates_captured_ids( async def test_build_dependencies_validates_captured_ids(
monkeypatch: pytest.MonkeyPatch, patched_side_effects monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None: ) -> 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] = {} seen: dict[str, Any] = {}
def _capture(**kwargs): def _capture(**kwargs):
@ -92,14 +92,14 @@ async def test_build_dependencies_validates_captured_ids(
monkeypatch.setattr(deps_mod, "assert_models_billable", _capture) 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) return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load) monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
await build_dependencies( await build_dependencies(
session=_FakeSession(SimpleNamespace(chat_model_id=0)), session=_FakeSession(SimpleNamespace(chat_model_id=0)),
search_space_id=42, workspace_id=42,
chat_model_id=-7, chat_model_id=-7,
image_gen_model_id=5, image_gen_model_id=5,
vision_model_id=-1, vision_model_id=-1,
@ -132,20 +132,20 @@ async def test_build_dependencies_raises_on_captured_policy_violation(
with pytest.raises(DependencyError): with pytest.raises(DependencyError):
await build_dependencies( await build_dependencies(
session=_FakeSession(SimpleNamespace(chat_model_id=-7)), session=_FakeSession(SimpleNamespace(chat_model_id=-7)),
search_space_id=42, workspace_id=42,
chat_model_id=-7, chat_model_id=-7,
image_gen_model_id=-2, image_gen_model_id=-2,
vision_model_id=-1, 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 monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None: ) -> None:
"""With no captured snapshot, resolve + validate the live search space.""" """With no captured snapshot, resolve + validate the live workspace."""
captured: dict[str, Any] = {} 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["config_id"] = config_id
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None) return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
@ -157,18 +157,18 @@ async def test_build_dependencies_falls_back_to_search_space(
lambda **_kw: pytest.fail("captured policy should not run on fallback path"), lambda **_kw: pytest.fail("captured policy should not run on fallback path"),
) )
search_space = SimpleNamespace(chat_model_id=-7) workspace = SimpleNamespace(chat_model_id=-7)
result = await build_dependencies( result = await build_dependencies(
session=_FakeSession(search_space), search_space_id=42 session=_FakeSession(workspace), workspace_id=42
) )
assert captured == {"config_id": -7} assert captured == {"config_id": -7}
assert result.llm.name == "llm" 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, patched_side_effects,
) -> None: ) -> None:
"""A missing search space (fallback path) surfaces as a ``DependencyError``.""" """A missing workspace (fallback path) surfaces as a ``DependencyError``."""
with pytest.raises(DependencyError): with pytest.raises(DependencyError):
await build_dependencies(session=_FakeSession(None), search_space_id=999) await build_dependencies(session=_FakeSession(None), workspace_id=999)

View file

@ -34,7 +34,7 @@ def _action_context() -> ActionContext:
session=cast(AsyncSession, None), session=cast(AsyncSession, None),
run_id=1, run_id=1,
step_id="s1", step_id="s1",
search_space_id=1, workspace_id=1,
creator_user_id=None, creator_user_id=None,
) )

View file

@ -1,6 +1,6 @@
"""Lock that the executor propagates the captured model snapshot into the """Lock that the executor propagates the captured model snapshot into the
``ActionContext``, so runs resolve their own model (insulated from chat / ``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 from __future__ import annotations
@ -21,7 +21,7 @@ pytestmark = pytest.mark.unit
def _run() -> SimpleNamespace: def _run() -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
id=1, 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, models,
) )
assert ctx.search_space_id == 42 assert ctx.workspace_id == 42
assert ctx.chat_model_id == -1 assert ctx.chat_model_id == -1
assert ctx.image_gen_model_id == 5 assert ctx.image_gen_model_id == 5
assert ctx.vision_model_id == -1 assert ctx.vision_model_id == -1

View file

@ -17,11 +17,11 @@ _VALID_DEFINITION = {
def test_automation_create_accepts_valid_minimal_payload() -> None: 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.""" Triggers default to ``[]`` so users can attach them later."""
payload = AutomationCreate.model_validate( payload = AutomationCreate.model_validate(
{ {
"search_space_id": 1, "workspace_id": 1,
"name": "Daily digest", "name": "Daily digest",
"definition": _VALID_DEFINITION, "definition": _VALID_DEFINITION,
} }
@ -39,7 +39,7 @@ def test_automation_create_cascades_validation_into_nested_definition() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AutomationCreate.model_validate( AutomationCreate.model_validate(
{ {
"search_space_id": 1, "workspace_id": 1,
"name": "Bad", "name": "Bad",
"definition": {"name": "X", "plan": []}, # empty plan "definition": {"name": "X", "plan": []}, # empty plan
} }
@ -51,7 +51,7 @@ def test_automation_create_rejects_unknown_top_level_field() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AutomationCreate.model_validate( AutomationCreate.model_validate(
{ {
"search_space_id": 1, "workspace_id": 1,
"name": "X", "name": "X",
"definition": _VALID_DEFINITION, "definition": _VALID_DEFINITION,
"owner": "tg", # not allowed "owner": "tg", # not allowed
@ -64,7 +64,7 @@ def test_automation_create_rejects_empty_name() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AutomationCreate.model_validate( AutomationCreate.model_validate(
{ {
"search_space_id": 1, "workspace_id": 1,
"name": "", "name": "",
"definition": _VALID_DEFINITION, "definition": _VALID_DEFINITION,
} }

View file

@ -1,6 +1,6 @@
"""Lock creation-time model-policy enforcement in ``AutomationService``. """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 billable for automations with HTTP 422, mirroring the runtime backstop. These
tests isolate the new ``_assert_models_billable`` / ``model_eligibility`` paths tests isolate the new ``_assert_models_billable`` / ``model_eligibility`` paths
without touching the DB commit. without touching the DB commit.
@ -29,13 +29,13 @@ pytestmark = pytest.mark.unit
class _FakeSession: class _FakeSession:
def __init__(self, search_space: Any) -> None: def __init__(self, workspace: Any) -> None:
self._search_space = search_space self._workspace = workspace
self.added: list[Any] = [] self.added: list[Any] = []
self.commits = 0 self.commits = 0
async def get(self, _model: Any, _pk: int) -> Any: async def get(self, _model: Any, _pk: int) -> Any:
return self._search_space return self._workspace
def add(self, obj: Any) -> None: def add(self, obj: Any) -> None:
self.added.append(obj) self.added.append(obj)
@ -44,9 +44,9 @@ class _FakeSession:
self.commits += 1 self.commits += 1
def _service(search_space: Any) -> AutomationService: def _service(workspace: Any) -> AutomationService:
return AutomationService( return AutomationService(
session=_FakeSession(search_space), session=_FakeSession(workspace),
auth=AuthContext.session(SimpleNamespace(id="u-1")), 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( async def test_assert_models_billable_raises_404_when_missing(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""A missing search space is a 404, not a policy error.""" """A missing workspace is a 404, not a policy error."""
monkeypatch.setattr( monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None 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 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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( monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None automation_mod, "assert_automation_models_billable", lambda _ss: None
) )
search_space = SimpleNamespace(chat_model_id=-1) workspace = SimpleNamespace(chat_model_id=-1)
service = _service(search_space) service = _service(workspace)
assert await service._assert_models_billable(1) is search_space 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""create() snapshots the search space's model prefs onto the definition.""" """create() snapshots the workspace's model prefs onto the definition."""
monkeypatch.setattr( monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None 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) monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
search_space = SimpleNamespace( workspace = SimpleNamespace(
chat_model_id=-1, chat_model_id=-1,
image_gen_model_id=5, image_gen_model_id=5,
vision_model_id=-1, vision_model_id=-1,
) )
service = _service(search_space) service = _service(workspace)
payload = AutomationCreate( payload = AutomationCreate(
search_space_id=1, workspace_id=1,
name="A", name="A",
definition=_definition(), 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( async def test_create_treats_unset_prefs_as_auto_zero(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""``None`` search-space prefs are captured as ``0`` (Auto) ids.""" """``None`` workspace prefs are captured as ``0`` (Auto) ids."""
monkeypatch.setattr( monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None 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) monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
search_space = SimpleNamespace( workspace = SimpleNamespace(
chat_model_id=None, chat_model_id=None,
image_gen_model_id=None, image_gen_model_id=None,
vision_model_id=None, vision_model_id=None,
) )
service = _service(search_space) service = _service(workspace)
payload = AutomationCreate(search_space_id=1, name="A", definition=_definition()) payload = AutomationCreate(workspace_id=1, name="A", definition=_definition())
automation = await service.create(payload) automation = await service.create(payload)
@ -185,7 +185,7 @@ async def test_create_honors_selected_models_when_provided(
) -> None: ) -> None:
"""When the payload carries ``definition.models`` they are validated + kept. """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). ``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)) service = _service(SimpleNamespace(chat_model_id=-99))
payload = AutomationCreate( payload = AutomationCreate(
search_space_id=1, workspace_id=1,
name="A", name="A",
definition=_definition( definition=_definition(
models=AutomationModels( models=AutomationModels(
@ -257,7 +257,7 @@ async def test_create_rejects_unbillable_selected_models(
service = _service(SimpleNamespace(chat_model_id=-3)) service = _service(SimpleNamespace(chat_model_id=-3))
payload = AutomationCreate( payload = AutomationCreate(
search_space_id=1, workspace_id=1,
name="A", name="A",
definition=_definition( definition=_definition(
models=AutomationModels( models=AutomationModels(
@ -284,7 +284,7 @@ async def test_update_preserves_captured_models(
"vision_model_id": -1, "vision_model_id": -1,
} }
existing = SimpleNamespace( existing = SimpleNamespace(
search_space_id=1, workspace_id=1,
definition={"name": "A", "plan": [], "models": captured}, definition={"name": "A", "plan": [], "models": captured},
version=3, version=3,
) )
@ -315,7 +315,7 @@ async def test_update_honors_changed_models_when_valid(
) -> None: ) -> None:
"""A definition edit with a *changed* models block validates + keeps it.""" """A definition edit with a *changed* models block validates + keeps it."""
existing = SimpleNamespace( existing = SimpleNamespace(
search_space_id=1, workspace_id=1,
definition={ definition={
"name": "A", "name": "A",
"plan": [], "plan": [],
@ -376,7 +376,7 @@ async def test_update_rejects_changed_unbillable_models(
) -> None: ) -> None:
"""A *changed* non-billable models block is rejected with HTTP 422.""" """A *changed* non-billable models block is rejected with HTTP 422."""
existing = SimpleNamespace( existing = SimpleNamespace(
search_space_id=1, workspace_id=1,
definition={ definition={
"name": "A", "name": "A",
"plan": [], "plan": [],
@ -438,7 +438,7 @@ async def test_update_keeps_unchanged_models_without_revalidation(
"vision_model_id": -1, "vision_model_id": -1,
} }
existing = SimpleNamespace( existing = SimpleNamespace(
search_space_id=1, workspace_id=1,
definition={"name": "A", "plan": [], "models": captured}, definition={"name": "A", "plan": [], "models": captured},
version=3, version=3,
) )
@ -477,7 +477,7 @@ async def test_model_eligibility_authorizes_and_returns_payload(
authorized: dict[str, Any] = {} authorized: dict[str, Any] = {}
async def _fake_check_permission(_session, _user, ss_id, permission, _msg): 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 authorized["permission"] = permission
monkeypatch.setattr(automation_mod, "check_permission", _fake_check_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)) 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 result == {"allowed": False, "violations": [{"kind": "image"}]}
assert authorized["search_space_id"] == 5 assert authorized["workspace_id"] == 5
assert authorized["permission"] == "automations:read" assert authorized["permission"] == "automations:read"

View file

@ -24,8 +24,8 @@ from app.automations.services.model_policy import (
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
def _search_space(*, llm: int | None, image: int | None, vision: int | None): def _workspace(*, llm: int | None, image: int | None, vision: int | None):
"""Minimal stand-in for the ``SearchSpace`` ORM row the policy reads.""" """Minimal stand-in for the ``Workspace`` ORM row the policy reads."""
return SimpleNamespace( return SimpleNamespace(
chat_model_id=llm, chat_model_id=llm,
image_gen_model_id=image, image_gen_model_id=image,
@ -95,15 +95,15 @@ def test_unknown_global_id_is_blocked(kind: str, patched_globals) -> None:
def test_eligibility_all_billable(patched_globals) -> None: def test_eligibility_all_billable(patched_globals) -> None:
"""Premium LLM + BYOK image + premium vision → allowed, no violations.""" """Premium LLM + BYOK image + premium vision → allowed, no violations."""
search_space = _search_space(llm=-1, image=5, vision=-1) workspace = _workspace(llm=-1, image=5, vision=-1)
result = get_automation_model_eligibility(search_space) result = get_automation_model_eligibility(workspace)
assert result == {"allowed": True, "violations": []} assert result == {"allowed": True, "violations": []}
def test_eligibility_reports_each_violation(patched_globals) -> None: def test_eligibility_reports_each_violation(patched_globals) -> None:
"""A free LLM, Auto image, and free vision each produce a violation.""" """A free LLM, Auto image, and free vision each produce a violation."""
search_space = _search_space(llm=-2, image=0, vision=-2) workspace = _workspace(llm=-2, image=0, vision=-2)
result = get_automation_model_eligibility(search_space) result = get_automation_model_eligibility(workspace)
assert result["allowed"] is False assert result["allowed"] is False
kinds = {v["kind"] for v in result["violations"]} kinds = {v["kind"] for v in result["violations"]}
@ -115,9 +115,9 @@ def test_eligibility_reports_each_violation(patched_globals) -> None:
def test_assert_raises_with_violations(patched_globals) -> None: def test_assert_raises_with_violations(patched_globals) -> None:
"""``assert_automation_models_billable`` raises when any slot is blocked.""" """``assert_automation_models_billable`` raises when any slot is blocked."""
search_space = _search_space(llm=0, image=5, vision=-1) workspace = _workspace(llm=0, image=5, vision=-1)
with pytest.raises(AutomationModelPolicyError) as exc_info: with pytest.raises(AutomationModelPolicyError) as exc_info:
assert_automation_models_billable(search_space) assert_automation_models_billable(workspace)
assert len(exc_info.value.violations) == 1 assert len(exc_info.value.violations) == 1
assert exc_info.value.violations[0]["kind"] == "chat" assert exc_info.value.violations[0]["kind"] == "chat"
@ -125,8 +125,8 @@ def test_assert_raises_with_violations(patched_globals) -> None:
def test_assert_passes_when_all_billable(patched_globals) -> None: def test_assert_passes_when_all_billable(patched_globals) -> None:
"""No exception when every slot is premium or BYOK.""" """No exception when every slot is premium or BYOK."""
search_space = _search_space(llm=3, image=-1, vision=4) workspace = _workspace(llm=3, image=-1, vision=4)
assert assert_automation_models_billable(search_space) is None assert assert_automation_models_billable(workspace) is None
# --- ID-based core (used by the runtime backstop against captured snapshots) --- # --- ID-based core (used by the runtime backstop against captured snapshots) ---
@ -170,9 +170,9 @@ def test_assert_models_billable_passes(patched_globals) -> None:
) )
def test_search_space_wrapper_delegates_to_core(patched_globals) -> None: def test_workspace_wrapper_delegates_to_core(patched_globals) -> None:
"""The search-space wrapper produces the same result as the ID core.""" """The workspace wrapper produces the same result as the ID core."""
search_space = _search_space(llm=-2, image=0, vision=-2) workspace = _workspace(llm=-2, image=0, vision=-2)
assert get_automation_model_eligibility(search_space) == get_model_eligibility( assert get_automation_model_eligibility(workspace) == get_model_eligibility(
chat_model_id=-2, image_gen_model_id=0, vision_model_id=-2 chat_model_id=-2, image_gen_model_id=0, vision_model_id=-2
) )

View file

@ -25,7 +25,7 @@ def test_build_run_context_exposes_run_inputs_and_steps_namespaces() -> None:
automation_id=7, automation_id=7,
automation_name="Weekly digest", automation_name="Weekly digest",
automation_version=3, automation_version=3,
search_space_id=1, workspace_id=1,
creator_id=creator, creator_id=creator,
trigger_id=11, trigger_id=11,
trigger_type="schedule", trigger_type="schedule",
@ -41,7 +41,7 @@ def test_build_run_context_exposes_run_inputs_and_steps_namespaces() -> None:
"automation_id": 7, "automation_id": 7,
"automation_name": "Weekly digest", "automation_name": "Weekly digest",
"automation_version": 3, "automation_version": 3,
"search_space_id": 1, "workspace_id": 1,
"creator_id": creator, "creator_id": creator,
"trigger_id": 11, "trigger_id": 11,
"trigger_type": "schedule", "trigger_type": "schedule",

View file

@ -21,9 +21,9 @@ def test_scalar_value_is_implicit_equality() -> None:
def test_multiple_fields_are_anded() -> None: def test_multiple_fields_are_anded() -> None:
flt = {"document_type": "FILE", "search_space_id": 7} flt = {"document_type": "FILE", "workspace_id": 7}
assert matches(flt, {"document_type": "FILE", "search_space_id": 7}) is True assert matches(flt, {"document_type": "FILE", "workspace_id": 7}) is True
assert matches(flt, {"document_type": "FILE", "search_space_id": 9}) is False assert matches(flt, {"document_type": "FILE", "workspace_id": 9}) is False
def test_gt_operator_compares_greater_than() -> None: def test_gt_operator_compares_greater_than() -> None:
@ -97,12 +97,12 @@ def test_missing_field_never_matches_and_never_raises() -> None:
def test_logical_operators_compose_with_fields() -> None: def test_logical_operators_compose_with_fields() -> None:
flt = { flt = {
"search_space_id": 7, "workspace_id": 7,
"$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}], "$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}],
} }
assert matches(flt, {"search_space_id": 7, "document_type": "FILE"}) is True assert matches(flt, {"workspace_id": 7, "document_type": "FILE"}) is True
assert matches(flt, {"search_space_id": 9, "document_type": "FILE"}) is False assert matches(flt, {"workspace_id": 9, "document_type": "FILE"}) is False
assert matches(flt, {"search_space_id": 7, "document_type": "SLACK"}) is False assert matches(flt, {"workspace_id": 7, "document_type": "SLACK"}) is False
def test_unknown_field_operator_raises_filter_error() -> None: def test_unknown_field_operator_raises_filter_error() -> None:

View file

@ -14,7 +14,7 @@ def test_runtime_inputs_flatten_payload_with_event_metadata() -> None:
event = Event( event = Event(
event_type="document.indexed", event_type="document.indexed",
payload={"document_id": 42, "document_type": "FILE"}, payload={"document_id": 42, "document_type": "FILE"},
search_space_id=7, workspace_id=7,
) )
inputs = event_runtime_inputs(event) inputs = event_runtime_inputs(event)

View file

@ -11,7 +11,7 @@ pytestmark = pytest.mark.unit
def _event(event_type: str = "document.indexed", **payload) -> Event: def _event(event_type: str = "document.indexed", **payload) -> Event:
return Event(event_type=event_type, payload=payload, search_space_id=7) return Event(event_type=event_type, payload=payload, workspace_id=7)
def test_matches_when_event_type_equal_and_filter_passes() -> None: def test_matches_when_event_type_equal_and_filter_passes() -> None:

View file

@ -69,7 +69,7 @@ async def test_build_connector_doc_produces_correct_fields():
page, page,
markdown, markdown,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -77,7 +77,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123" assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.CONFLUENCE_CONNECTOR assert doc.document_type == DocumentType.CONFLUENCE_CONNECTOR
assert doc.source_markdown == markdown assert doc.source_markdown == markdown
assert doc.search_space_id == _SEARCH_SPACE_ID assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID assert doc.created_by_id == _USER_ID
assert doc.metadata["page_id"] == "abc-123" assert doc.metadata["page_id"] == "abc-123"
@ -181,7 +181,7 @@ async def _run_index(mocks, **overrides):
return await index_confluence_pages( return await index_confluence_pages(
session=mocks["session"], session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID), connector_id=overrides.get("connector_id", _CONNECTOR_ID),
search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID), workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID), user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"), start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"), end_date=overrides.get("end_date", "2025-12-31"),

View file

@ -69,7 +69,7 @@ async def test_single_file_returns_one_connector_document(
mock_dropbox_client, mock_dropbox_client,
[_make_file_dict("f1", "test.txt")], [_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -94,7 +94,7 @@ async def test_multiple_files_all_produce_documents(
mock_dropbox_client, mock_dropbox_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -121,7 +121,7 @@ async def test_one_download_exception_does_not_block_others(
mock_dropbox_client, mock_dropbox_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -147,7 +147,7 @@ async def test_etl_error_counts_as_download_failure(
mock_dropbox_client, mock_dropbox_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -185,7 +185,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_dropbox_client, mock_dropbox_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
max_concurrency=2, max_concurrency=2,
) )
@ -224,7 +224,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_dropbox_client, mock_dropbox_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
on_heartbeat=_on_heartbeat, on_heartbeat=_on_heartbeat,
) )
@ -257,7 +257,7 @@ def full_scan_mocks(mock_dropbox_client, monkeypatch):
monkeypatch.setattr("app.config.config.ETL_SERVICE", "LLAMACLOUD") monkeypatch.setattr("app.config.config.ETL_SERVICE", "LLAMACLOUD")
async def _fake_skip(session, file, search_space_id): async def _fake_skip(session, file, workspace_id):
from app.connectors.dropbox.file_types import should_skip_file as _skip from app.connectors.dropbox.file_types import should_skip_file as _skip
item_skip, unsup_ext = _skip(file) item_skip, unsup_ext = _skip(file)
@ -389,7 +389,7 @@ def selected_files_mocks(mock_dropbox_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {} skip_results: dict[str, tuple[bool, str | None]] = {}
async def _fake_skip(session, file, search_space_id): async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None)) return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip) monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@ -430,7 +430,7 @@ async def _run_selected(mocks, file_tuples):
mocks["session"], mocks["session"],
file_tuples, file_tuples,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -547,7 +547,7 @@ async def test_delta_sync_deletions_call_remove_document(monkeypatch):
remove_calls: list[str] = [] remove_calls: list[str] = []
async def _fake_remove(session, file_id, search_space_id): async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id) remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove) monkeypatch.setattr(_mod, "_remove_document", _fake_remove)
@ -641,7 +641,7 @@ async def test_delta_sync_mix_deletions_and_upserts(monkeypatch):
remove_calls: list[str] = [] remove_calls: list[str] = []
async def _fake_remove(session, file_id, search_space_id): async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id) remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove) monkeypatch.setattr(_mod, "_remove_document", _fake_remove)

View file

@ -201,7 +201,7 @@ async def _run_gdrive_selected(mocks, file_ids):
mocks["session"], mocks["session"],
file_ids, file_ids,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -542,7 +542,7 @@ async def _run_onedrive_selected(mocks, file_ids):
mocks["session"], mocks["session"],
file_ids, file_ids,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -641,7 +641,7 @@ async def _run_dropbox_selected(mocks, file_paths):
mocks["session"], mocks["session"],
file_paths, file_paths,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )

View file

@ -64,7 +64,7 @@ async def test_single_file_returns_one_connector_document(
mock_drive_client, mock_drive_client,
[_make_file_dict("f1", "test.txt")], [_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -88,7 +88,7 @@ async def test_multiple_files_all_produce_documents(
mock_drive_client, mock_drive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -115,7 +115,7 @@ async def test_one_download_exception_does_not_block_others(
mock_drive_client, mock_drive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -141,7 +141,7 @@ async def test_etl_error_counts_as_download_failure(
mock_drive_client, mock_drive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -180,7 +180,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_drive_client, mock_drive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
max_concurrency=2, max_concurrency=2,
) )
@ -219,7 +219,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_drive_client, mock_drive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
on_heartbeat=_on_heartbeat, on_heartbeat=_on_heartbeat,
) )
@ -284,7 +284,7 @@ def full_scan_mocks(mock_drive_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {} skip_results: dict[str, tuple[bool, str | None]] = {}
async def _fake_skip(session, file, search_space_id): async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None)) return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip) monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@ -458,7 +458,7 @@ async def test_delta_sync_removals_serial_rest_parallel(monkeypatch):
remove_calls: list[str] = [] remove_calls: list[str] = []
async def _fake_remove(session, file_id, search_space_id): async def _fake_remove(session, file_id, workspace_id):
remove_calls.append(file_id) remove_calls.append(file_id)
monkeypatch.setattr(_mod, "_remove_document", _fake_remove) monkeypatch.setattr(_mod, "_remove_document", _fake_remove)
@ -532,7 +532,7 @@ def selected_files_mocks(mock_drive_client, monkeypatch):
skip_results: dict[str, tuple[bool, str | None]] = {} skip_results: dict[str, tuple[bool, str | None]] = {}
async def _fake_skip(session, file, search_space_id): async def _fake_skip(session, file, workspace_id):
return skip_results.get(file["id"], (False, None)) return skip_results.get(file["id"], (False, None))
monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip) monkeypatch.setattr(_mod, "_should_skip_file", _fake_skip)
@ -563,7 +563,7 @@ async def _run_selected(mocks, file_ids):
mocks["session"], mocks["session"],
file_ids, file_ids,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )

View file

@ -68,7 +68,7 @@ async def test_build_connector_doc_produces_correct_fields():
formatted, formatted,
markdown, markdown,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -76,7 +76,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123" assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.LINEAR_CONNECTOR assert doc.document_type == DocumentType.LINEAR_CONNECTOR
assert doc.source_markdown == markdown assert doc.source_markdown == markdown
assert doc.search_space_id == _SEARCH_SPACE_ID assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID assert doc.created_by_id == _USER_ID
assert doc.metadata["issue_id"] == "abc-123" assert doc.metadata["issue_id"] == "abc-123"
@ -196,7 +196,7 @@ async def _run_index(mocks, **overrides):
return await index_linear_issues( return await index_linear_issues(
session=mocks["session"], session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID), connector_id=overrides.get("connector_id", _CONNECTOR_ID),
search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID), workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID), user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"), start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"), end_date=overrides.get("end_date", "2025-12-31"),

View file

@ -39,7 +39,7 @@ async def test_build_connector_doc_produces_correct_fields():
page, page,
markdown, markdown,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -47,7 +47,7 @@ async def test_build_connector_doc_produces_correct_fields():
assert doc.unique_id == "abc-123" assert doc.unique_id == "abc-123"
assert doc.document_type == DocumentType.NOTION_CONNECTOR assert doc.document_type == DocumentType.NOTION_CONNECTOR
assert doc.source_markdown == markdown assert doc.source_markdown == markdown
assert doc.search_space_id == _SEARCH_SPACE_ID assert doc.workspace_id == _SEARCH_SPACE_ID
assert doc.connector_id == _CONNECTOR_ID assert doc.connector_id == _CONNECTOR_ID
assert doc.created_by_id == _USER_ID assert doc.created_by_id == _USER_ID
assert doc.metadata["page_title"] == "My Notion Page" assert doc.metadata["page_title"] == "My Notion Page"
@ -159,7 +159,7 @@ async def _run_index(mocks, **overrides):
return await index_notion_pages( return await index_notion_pages(
session=mocks["session"], session=mocks["session"],
connector_id=overrides.get("connector_id", _CONNECTOR_ID), connector_id=overrides.get("connector_id", _CONNECTOR_ID),
search_space_id=overrides.get("search_space_id", _SEARCH_SPACE_ID), workspace_id=overrides.get("workspace_id", _SEARCH_SPACE_ID),
user_id=overrides.get("user_id", _USER_ID), user_id=overrides.get("user_id", _USER_ID),
start_date=overrides.get("start_date", "2025-01-01"), start_date=overrides.get("start_date", "2025-01-01"),
end_date=overrides.get("end_date", "2025-12-31"), end_date=overrides.get("end_date", "2025-12-31"),

View file

@ -63,7 +63,7 @@ async def test_single_file_returns_one_connector_document(
mock_onedrive_client, mock_onedrive_client,
[_make_file_dict("f1", "test.txt")], [_make_file_dict("f1", "test.txt")],
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -88,7 +88,7 @@ async def test_multiple_files_all_produce_documents(
mock_onedrive_client, mock_onedrive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -115,7 +115,7 @@ async def test_one_download_exception_does_not_block_others(
mock_onedrive_client, mock_onedrive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -141,7 +141,7 @@ async def test_etl_error_counts_as_download_failure(
mock_onedrive_client, mock_onedrive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
) )
@ -179,7 +179,7 @@ async def test_concurrency_bounded_by_semaphore(
mock_onedrive_client, mock_onedrive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
max_concurrency=2, max_concurrency=2,
) )
@ -218,7 +218,7 @@ async def test_heartbeat_fires_during_parallel_downloads(
mock_onedrive_client, mock_onedrive_client,
files, files,
connector_id=_CONNECTOR_ID, connector_id=_CONNECTOR_ID,
search_space_id=_SEARCH_SPACE_ID, workspace_id=_SEARCH_SPACE_ID,
user_id=_USER_ID, user_id=_USER_ID,
on_heartbeat=_on_heartbeat, on_heartbeat=_on_heartbeat,
) )

View file

@ -13,7 +13,7 @@ pytestmark = pytest.mark.unit
def _event() -> Event: def _event() -> Event:
return Event(event_type="x.happened", payload={"k": "v"}, search_space_id=1) return Event(event_type="x.happened", payload={"k": "v"}, workspace_id=1)
async def _noop(_event: Event) -> None: async def _noop(_event: Event) -> None:
@ -152,13 +152,13 @@ async def test_publish_builds_a_stamped_event_and_fans_it_out() -> None:
received.append(event) received.append(event)
bus.subscribe(handler) bus.subscribe(handler)
await bus.publish("document.indexed", {"document_id": 42}, search_space_id=7) await bus.publish("document.indexed", {"document_id": 42}, workspace_id=7)
assert len(received) == 1 assert len(received) == 1
event = received[0] event = received[0]
assert event.event_type == "document.indexed" assert event.event_type == "document.indexed"
assert event.payload == {"document_id": 42} assert event.payload == {"document_id": 42}
assert event.search_space_id == 7 assert event.workspace_id == 7
# Engine-stamped identity/time on the way through. # Engine-stamped identity/time on the way through.
assert event.event_id assert event.event_id
assert event.occurred_at assert event.occurred_at
@ -172,10 +172,10 @@ async def test_publish_defaults_payload_to_empty_dict() -> None:
received.append(event) received.append(event)
bus.subscribe(handler) bus.subscribe(handler)
await bus.publish("x.happened", search_space_id=1) await bus.publish("x.happened", workspace_id=1)
assert received[0].payload == {} assert received[0].payload == {}
async def test_publish_with_no_subscribers_is_a_noop() -> None: async def test_publish_with_no_subscribers_is_a_noop() -> None:
await EventBus().publish("x.happened", search_space_id=1) # must not raise await EventBus().publish("x.happened", workspace_id=1) # must not raise

View file

@ -14,7 +14,7 @@ pytestmark = pytest.mark.unit
def _call(**overrides: Any) -> dict[str, Any] | None: def _call(**overrides: Any) -> dict[str, Any] | None:
defaults: dict[str, Any] = { defaults: dict[str, Any] = {
"document_id": 1, "document_id": 1,
"search_space_id": 10, "workspace_id": 10,
"new_folder_id": 7, "new_folder_id": 7,
"previous_folder_id": None, "previous_folder_id": None,
"folder_id_changed": True, "folder_id_changed": True,
@ -33,7 +33,7 @@ def test_folder_set_ready_fires() -> None:
assert result is not None assert result is not None
assert result["event_type"] == "document.entered_folder" assert result["event_type"] == "document.entered_folder"
assert result["search_space_id"] == 10 assert result["workspace_id"] == 10
assert result["payload"]["folder_id"] == 7 assert result["payload"]["folder_id"] == 7
assert result["payload"]["previous_folder_id"] is None assert result["payload"]["previous_folder_id"] is None

View file

@ -16,17 +16,17 @@ def test_event_carries_caller_supplied_facts() -> None:
event = Event( event = Event(
event_type="document.indexed", event_type="document.indexed",
payload={"document_id": 42, "content_type": "pdf"}, payload={"document_id": 42, "content_type": "pdf"},
search_space_id=7, workspace_id=7,
) )
assert event.event_type == "document.indexed" assert event.event_type == "document.indexed"
assert event.payload == {"document_id": 42, "content_type": "pdf"} assert event.payload == {"document_id": 42, "content_type": "pdf"}
assert event.search_space_id == 7 assert event.workspace_id == 7
def test_event_stamps_identity_and_time_when_not_supplied() -> None: def test_event_stamps_identity_and_time_when_not_supplied() -> None:
"""Engine stamps id + time so subscribers can dedup/order.""" """Engine stamps id + time so subscribers can dedup/order."""
event = Event(event_type="x.happened", payload={}, search_space_id=1) event = Event(event_type="x.happened", payload={}, workspace_id=1)
assert event.event_id assert event.event_id
assert isinstance(event.occurred_at, datetime) assert isinstance(event.occurred_at, datetime)
@ -34,8 +34,8 @@ def test_event_stamps_identity_and_time_when_not_supplied() -> None:
def test_event_ids_are_unique_per_instance() -> None: def test_event_ids_are_unique_per_instance() -> None:
"""Two events published with identical content are still distinct facts.""" """Two events published with identical content are still distinct facts."""
first = Event(event_type="x.happened", payload={}, search_space_id=1) first = Event(event_type="x.happened", payload={}, workspace_id=1)
second = Event(event_type="x.happened", payload={}, search_space_id=1) second = Event(event_type="x.happened", payload={}, workspace_id=1)
assert first.event_id != second.event_id assert first.event_id != second.event_id
@ -45,7 +45,7 @@ def test_event_survives_json_round_trip() -> None:
original = Event( original = Event(
event_type="podcast.generated", event_type="podcast.generated",
payload={"podcast_id": 9, "duration_s": 123.5}, payload={"podcast_id": 9, "duration_s": 123.5},
search_space_id=3, workspace_id=3,
) )
restored = Event.model_validate_json(original.model_dump_json()) restored = Event.model_validate_json(original.model_dump_json())

View file

@ -330,10 +330,10 @@ async def test_discord_gateway_install_returns_oauth_url(monkeypatch, mocker):
"http://localhost:8000/api/v1/gateway/discord/callback", "http://localhost:8000/api/v1/gateway/discord/callback",
) )
monkeypatch.setattr(routes.config, "SECRET_KEY", "test-secret") monkeypatch.setattr(routes.config, "SECRET_KEY", "test-secret")
monkeypatch.setattr(routes, "check_search_space_access", mocker.AsyncMock()) monkeypatch.setattr(routes, "check_workspace_access", mocker.AsyncMock())
response = await routes.install_discord_gateway( response = await routes.install_discord_gateway(
search_space_id=123, workspace_id=123,
auth=AuthContext.session( auth=AuthContext.session(
SimpleNamespace(id="00000000-0000-0000-0000-000000000001") SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
), ),

View file

@ -14,7 +14,7 @@ def test_valid_document_created_with_required_fields():
source_markdown="## Task\n\nSome content.", source_markdown="## Task\n\nSome content.",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
connector_id=42, connector_id=42,
created_by_id="00000000-0000-0000-0000-000000000001", created_by_id="00000000-0000-0000-0000-000000000001",
) )
@ -32,7 +32,7 @@ def test_omitting_created_by_id_raises():
source_markdown="## Content", source_markdown="## Content",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
connector_id=42, connector_id=42,
) )
@ -45,7 +45,7 @@ def test_empty_source_markdown_raises():
source_markdown="", source_markdown="",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
) )
@ -57,7 +57,7 @@ def test_whitespace_only_source_markdown_raises():
source_markdown=" \n\t ", source_markdown=" \n\t ",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
) )
@ -69,7 +69,7 @@ def test_empty_title_raises():
source_markdown="## Content", source_markdown="## Content",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
) )
@ -81,21 +81,21 @@ def test_empty_created_by_id_raises():
source_markdown="## Content", source_markdown="## Content",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
connector_id=42, connector_id=42,
created_by_id="", created_by_id="",
) )
def test_zero_search_space_id_raises(): def test_zero_workspace_id_raises():
"""search_space_id of zero raises a validation error.""" """workspace_id of zero raises a validation error."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
ConnectorDocument( ConnectorDocument(
title="Task", title="Task",
source_markdown="## Content", source_markdown="## Content",
unique_id="task-1", unique_id="task-1",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=0, workspace_id=0,
connector_id=42, connector_id=42,
created_by_id="00000000-0000-0000-0000-000000000001", created_by_id="00000000-0000-0000-0000-000000000001",
) )
@ -109,5 +109,5 @@ def test_empty_unique_id_raises():
source_markdown="## Content", source_markdown="## Content",
unique_id="", unique_id="",
document_type=DocumentType.CLICKUP_CONNECTOR, document_type=DocumentType.CLICKUP_CONNECTOR,
search_space_id=1, workspace_id=1,
) )

View file

@ -27,7 +27,7 @@ def _make_placeholder(**overrides) -> PlaceholderInfo:
"title": "Test Doc", "title": "Test Doc",
"document_type": DocumentType.GOOGLE_DRIVE_FILE, "document_type": DocumentType.GOOGLE_DRIVE_FILE,
"unique_id": "file-001", "unique_id": "file-001",
"search_space_id": 1, "workspace_id": 1,
"connector_id": 42, "connector_id": 42,
"created_by_id": "00000000-0000-0000-0000-000000000001", "created_by_id": "00000000-0000-0000-0000-000000000001",
} }
@ -37,7 +37,7 @@ def _make_placeholder(**overrides) -> PlaceholderInfo:
def _uid_hash(p: PlaceholderInfo) -> str: def _uid_hash(p: PlaceholderInfo) -> str:
return compute_identifier_hash( return compute_identifier_hash(
p.document_type.value, p.unique_id, p.search_space_id p.document_type.value, p.unique_id, p.workspace_id
) )
@ -82,7 +82,7 @@ async def test_creates_documents_with_pending_status_and_commits():
assert doc.document_type == DocumentType.GOOGLE_DRIVE_FILE assert doc.document_type == DocumentType.GOOGLE_DRIVE_FILE
assert doc.content == "Pending..." assert doc.content == "Pending..."
assert DocumentStatus.is_state(doc.status, DocumentStatus.PENDING) assert DocumentStatus.is_state(doc.status, DocumentStatus.PENDING)
assert doc.search_space_id == 1 assert doc.workspace_id == 1
assert doc.connector_id == 42 assert doc.connector_id == 42
session.commit.assert_awaited_once() session.commit.assert_awaited_once()

View file

@ -19,12 +19,12 @@ def test_different_unique_id_produces_different_hash(make_connector_document):
) )
def test_different_search_space_produces_different_identifier_hash( def test_different_workspace_produces_different_identifier_hash(
make_connector_document, make_connector_document,
): ):
"""Same document in different search spaces produces different identifier hashes.""" """Same document in different workspaces produces different identifier hashes."""
doc_a = make_connector_document(search_space_id=1) doc_a = make_connector_document(workspace_id=1)
doc_b = make_connector_document(search_space_id=2) doc_b = make_connector_document(workspace_id=2)
assert compute_unique_identifier_hash(doc_a) != compute_unique_identifier_hash( assert compute_unique_identifier_hash(doc_a) != compute_unique_identifier_hash(
doc_b doc_b
) )
@ -42,18 +42,18 @@ def test_different_document_type_produces_different_identifier_hash(
def test_same_content_same_space_produces_same_content_hash(make_connector_document): def test_same_content_same_space_produces_same_content_hash(make_connector_document):
"""Identical content in the same search space always produces the same content hash.""" """Identical content in the same workspace always produces the same content hash."""
doc_a = make_connector_document(source_markdown="Hello world", search_space_id=1) doc_a = make_connector_document(source_markdown="Hello world", workspace_id=1)
doc_b = make_connector_document(source_markdown="Hello world", search_space_id=1) doc_b = make_connector_document(source_markdown="Hello world", workspace_id=1)
assert compute_content_hash(doc_a) == compute_content_hash(doc_b) assert compute_content_hash(doc_a) == compute_content_hash(doc_b)
def test_same_content_different_space_produces_different_content_hash( def test_same_content_different_space_produces_different_content_hash(
make_connector_document, make_connector_document,
): ):
"""Identical content in different search spaces produces different content hashes.""" """Identical content in different workspaces produces different content hashes."""
doc_a = make_connector_document(source_markdown="Hello world", search_space_id=1) doc_a = make_connector_document(source_markdown="Hello world", workspace_id=1)
doc_b = make_connector_document(source_markdown="Hello world", search_space_id=2) doc_b = make_connector_document(source_markdown="Hello world", workspace_id=2)
assert compute_content_hash(doc_a) != compute_content_hash(doc_b) assert compute_content_hash(doc_a) != compute_content_hash(doc_b)
@ -69,7 +69,7 @@ def test_compute_identifier_hash_matches_connector_doc_hash(make_connector_docum
doc = make_connector_document( doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-123", unique_id="msg-123",
search_space_id=5, workspace_id=5,
) )
raw_hash = compute_identifier_hash("GOOGLE_GMAIL_CONNECTOR", "msg-123", 5) raw_hash = compute_identifier_hash("GOOGLE_GMAIL_CONNECTOR", "msg-123", 5)
assert raw_hash == compute_unique_identifier_hash(doc) assert raw_hash == compute_unique_identifier_hash(doc)

View file

@ -24,12 +24,12 @@ async def test_calls_prepare_then_index_per_document(pipeline, make_connector_do
doc1 = make_connector_document( doc1 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1", unique_id="msg-1",
search_space_id=1, workspace_id=1,
) )
doc2 = make_connector_document( doc2 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-2", unique_id="msg-2",
search_space_id=1, workspace_id=1,
) )
orm1 = MagicMock(spec=Document) orm1 = MagicMock(spec=Document)
@ -63,7 +63,7 @@ async def test_skips_document_without_matching_connector_doc(
doc1 = make_connector_document( doc1 = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1", unique_id="msg-1",
search_space_id=1, workspace_id=1,
) )
orphan_orm = MagicMock(spec=Document) orphan_orm = MagicMock(spec=Document)

View file

@ -82,7 +82,7 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1", unique_id="msg-1",
search_space_id=1, workspace_id=1,
) )
document = MagicMock(spec=Document) document = MagicMock(spec=Document)
document.id = 1 document.id = 1
@ -140,7 +140,7 @@ async def test_non_code_documents_use_hybrid_chunker(
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1", unique_id="msg-1",
search_space_id=1, workspace_id=1,
should_use_code_chunker=False, should_use_code_chunker=False,
) )
document = MagicMock(spec=Document) document = MagicMock(spec=Document)
@ -184,7 +184,7 @@ async def test_batch_parallel_indexes_all_documents(
make_connector_document( make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=f"msg-{i}", unique_id=f"msg-{i}",
search_space_id=1, workspace_id=1,
) )
for i in range(3) for i in range(3)
] ]
@ -222,7 +222,7 @@ async def test_batch_parallel_one_failure_does_not_affect_others(
make_connector_document( make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=f"msg-{i}", unique_id=f"msg-{i}",
search_space_id=1, workspace_id=1,
) )
for i in range(3) for i in range(3)
] ]

View file

@ -42,7 +42,7 @@ async def test_updates_hash_and_type_for_legacy_document(
doc = make_connector_document( doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-abc", unique_id="msg-abc",
search_space_id=1, workspace_id=1,
) )
legacy_hash = compute_identifier_hash("COMPOSIO_GMAIL_CONNECTOR", "msg-abc", 1) legacy_hash = compute_identifier_hash("COMPOSIO_GMAIL_CONNECTOR", "msg-abc", 1)
@ -70,7 +70,7 @@ async def test_noop_when_no_legacy_document_exists(
doc = make_connector_document( doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-xyz", unique_id="msg-xyz",
search_space_id=1, workspace_id=1,
) )
result_mock = MagicMock() result_mock = MagicMock()
@ -89,7 +89,7 @@ async def test_skips_non_google_doc_types(
doc = make_connector_document( doc = make_connector_document(
document_type=DocumentType.SLACK_CONNECTOR, document_type=DocumentType.SLACK_CONNECTOR,
unique_id="slack-123", unique_id="slack-123",
search_space_id=1, workspace_id=1,
) )
await pipeline.migrate_legacy_docs([doc]) await pipeline.migrate_legacy_docs([doc])
@ -111,7 +111,7 @@ async def test_handles_all_three_google_types(
doc = make_connector_document( doc = make_connector_document(
document_type=native_type, document_type=native_type,
unique_id="id-1", unique_id="id-1",
search_space_id=1, workspace_id=1,
) )
existing = MagicMock(spec=Document) existing = MagicMock(spec=Document)

View file

@ -32,7 +32,7 @@ def _make_connector_doc(**overrides) -> ConnectorDocument:
"source_markdown": "## Some new content", "source_markdown": "## Some new content",
"unique_id": "file-001", "unique_id": "file-001",
"document_type": DocumentType.GOOGLE_DRIVE_FILE, "document_type": DocumentType.GOOGLE_DRIVE_FILE,
"search_space_id": 1, "workspace_id": 1,
"connector_id": 42, "connector_id": 42,
"created_by_id": "00000000-0000-0000-0000-000000000001", "created_by_id": "00000000-0000-0000-0000-000000000001",
} }

View file

@ -39,11 +39,11 @@ pytestmark = pytest.mark.unit
def _make_middleware(mode: FilesystemMode = FilesystemMode.CLOUD): def _make_middleware(mode: FilesystemMode = FilesystemMode.CLOUD):
selection = FilesystemSelection(mode=mode) selection = FilesystemSelection(mode=mode)
resolver = build_backend_resolver(selection, search_space_id=1) resolver = build_backend_resolver(selection, workspace_id=1)
return build_filesystem_mw( return build_filesystem_mw(
backend_resolver=resolver, backend_resolver=resolver,
filesystem_mode=mode, filesystem_mode=mode,
search_space_id=1, workspace_id=1,
user_id="00000000-0000-0000-0000-000000000001", user_id="00000000-0000-0000-0000-000000000001",
thread_id=1, thread_id=1,
) )
@ -290,7 +290,7 @@ class TestKBPostgresBackendDeleteFilter:
def _make_backend(self, state: dict[str, Any]) -> KBPostgresBackend: def _make_backend(self, state: dict[str, Any]) -> KBPostgresBackend:
runtime = SimpleNamespace(state=state) runtime = SimpleNamespace(state=state)
return KBPostgresBackend(search_space_id=1, runtime=runtime) return KBPostgresBackend(workspace_id=1, runtime=runtime)
def test_pending_filesystem_view_returns_deleted_paths(self): def test_pending_filesystem_view_returns_deleted_paths(self):
backend = self._make_backend( backend = self._make_backend(

View file

@ -38,16 +38,16 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P
def test_backend_resolver_uses_cloud_mode_by_default(): def test_backend_resolver_uses_cloud_mode_by_default():
resolver = build_backend_resolver(FilesystemSelection()) resolver = build_backend_resolver(FilesystemSelection())
backend = resolver(_RuntimeStub()) backend = resolver(_RuntimeStub())
# When no search_space_id is provided we fall back to StateBackend so # When no workspace_id is provided we fall back to StateBackend so
# sub-agents / tests without DB access still work. # sub-agents / tests without DB access still work.
assert backend.__class__.__name__ == "StateBackend" assert backend.__class__.__name__ == "StateBackend"
def test_backend_resolver_uses_kb_postgres_in_cloud_with_search_space(): def test_backend_resolver_uses_kb_postgres_in_cloud_with_workspace():
resolver = build_backend_resolver(FilesystemSelection(), search_space_id=42) resolver = build_backend_resolver(FilesystemSelection(), workspace_id=42)
backend = resolver(_RuntimeStub()) backend = resolver(_RuntimeStub())
assert backend.__class__.__name__ == "KBPostgresBackend" assert backend.__class__.__name__ == "KBPostgresBackend"
assert backend.search_space_id == 42 assert backend.workspace_id == 42
def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path: Path): def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path: Path):

View file

@ -92,7 +92,7 @@ async def test_create_document_allows_identical_content_at_different_paths() ->
session, # type: ignore[arg-type] session, # type: ignore[arg-type]
virtual_path="/documents/a/notes.md", virtual_path="/documents/a/notes.md",
content=content, content=content,
search_space_id=42, workspace_id=42,
created_by_id="user-1", created_by_id="user-1",
) )
assert isinstance(first, Document) assert isinstance(first, Document)
@ -104,7 +104,7 @@ async def test_create_document_allows_identical_content_at_different_paths() ->
session, # type: ignore[arg-type] session, # type: ignore[arg-type]
virtual_path="/documents/b/notes-copy.md", virtual_path="/documents/b/notes-copy.md",
content=content, content=content,
search_space_id=42, workspace_id=42,
created_by_id="user-1", created_by_id="user-1",
) )
assert isinstance(second, Document) assert isinstance(second, Document)
@ -121,7 +121,7 @@ async def test_create_document_still_rejects_path_collision() -> None:
"""Path uniqueness remains the hard invariant. """Path uniqueness remains the hard invariant.
If ``unique_identifier_hash`` already points at an existing row in If ``unique_identifier_hash`` already points at an existing row in
the same search space, the create call must raise ``ValueError`` the same workspace, the create call must raise ``ValueError``
with a clear message matching the behavior the commit loop relies with a clear message matching the behavior the commit loop relies
on to upsert via the existing-row code path. on to upsert via the existing-row code path.
""" """
@ -137,7 +137,7 @@ async def test_create_document_still_rejects_path_collision() -> None:
session, # type: ignore[arg-type] session, # type: ignore[arg-type]
virtual_path="/documents/notes.md", virtual_path="/documents/notes.md",
content="anything", content="anything",
search_space_id=42, workspace_id=42,
created_by_id="user-1", created_by_id="user-1",
) )
@ -160,7 +160,7 @@ async def test_create_document_does_not_query_for_content_hash_collision(
session, # type: ignore[arg-type] session, # type: ignore[arg-type]
virtual_path="/documents/notes.md", virtual_path="/documents/notes.md",
content="hello", content="hello",
search_space_id=42, workspace_id=42,
created_by_id="user-1", created_by_id="user-1",
) )
# Path-collision SELECT only. No content_hash SELECT. # Path-collision SELECT only. No content_hash SELECT.

Some files were not shown because too many files have changed in this diff Show more