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

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

View file

@ -48,7 +48,7 @@ def _axis(index: int) -> list[float]:
async def _add_document(
db_session,
*,
search_space_id: int,
workspace_id: int,
title: str,
text: str,
folder_id: int | None = None,
@ -58,7 +58,7 @@ async def _add_document(
document_type=DocumentType.FILE,
content=text,
content_hash=uuid.uuid4().hex,
search_space_id=search_space_id,
workspace_id=workspace_id,
folder_id=folder_id,
status={"state": "ready"},
)
@ -71,8 +71,8 @@ async def _add_document(
return document
async def _add_folder(db_session, *, search_space_id: int, name: str = "Folder"):
folder = Folder(name=name, position="0", search_space_id=search_space_id)
async def _add_folder(db_session, *, workspace_id: int, name: str = "Folder"):
folder = Folder(name=name, position="0", workspace_id=workspace_id)
db_session.add(folder)
await db_session.flush()
return folder
@ -109,15 +109,15 @@ def _mentions(*, document_ids=(), folder_ids=()):
async def test_tool_returns_retrieved_context_with_numbered_passages(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio")
@ -129,15 +129,15 @@ async def test_tool_returns_retrieved_context_with_numbered_passages(
async def test_tool_populates_citation_registry_on_state(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio")
@ -147,15 +147,15 @@ async def test_tool_populates_citation_registry_on_state(
async def test_tool_reuses_existing_registry_numbering(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Asyncio Guide",
text="The asyncio library enables concurrency.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
first = await _invoke(tool, "asyncio")
carried = first.update["citation_registry"]
@ -166,9 +166,9 @@ async def test_tool_reuses_existing_registry_numbering(
async def test_tool_reports_no_matches_without_touching_state(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "nonexistent-term-zzz")
@ -177,9 +177,9 @@ async def test_tool_reports_no_matches_without_touching_state(
async def test_tool_rejects_empty_query(
db_search_space, _tool_uses_test_session, _pinned_embedding
db_workspace, _tool_uses_test_session, _pinned_embedding
):
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, " ")
@ -188,21 +188,21 @@ async def test_tool_rejects_empty_query(
async def test_document_mention_confines_search_to_pinned_doc(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
pinned = await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Pinned",
text="asyncio appears in the pinned doc.",
)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Other",
text="asyncio appears in the other doc.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio", context=_mentions(document_ids=[pinned.id]))
@ -213,23 +213,23 @@ async def test_document_mention_confines_search_to_pinned_doc(
async def test_folder_mention_confines_search_to_folder_documents(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
folder = await _add_folder(db_session, search_space_id=db_search_space.id)
folder = await _add_folder(db_session, workspace_id=db_workspace.id)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Inside",
text="asyncio appears inside the folder.",
folder_id=folder.id,
)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Outside",
text="asyncio appears outside the folder.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(tool, "asyncio", context=_mentions(folder_ids=[folder.id]))
@ -240,7 +240,7 @@ async def test_folder_mention_confines_search_to_folder_documents(
async def test_document_mention_via_state_confines_search(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""The real subagent path: mentions arrive on ``runtime.state`` (no context).
@ -250,17 +250,17 @@ async def test_document_mention_via_state_confines_search(
"""
pinned = await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Pinned",
text="asyncio appears in the pinned doc.",
)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Other",
text="asyncio appears in the other doc.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,
@ -275,24 +275,24 @@ async def test_document_mention_via_state_confines_search(
async def test_folder_mention_via_state_confines_search(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""Folder pins delivered via state (subagent path) scope to the folder's docs."""
folder = await _add_folder(db_session, search_space_id=db_search_space.id)
folder = await _add_folder(db_session, workspace_id=db_workspace.id)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Inside",
text="asyncio appears inside the folder.",
folder_id=folder.id,
)
await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="Outside",
text="asyncio appears outside the folder.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,
@ -307,22 +307,22 @@ async def test_folder_mention_via_state_confines_search(
async def test_state_mentions_take_precedence_over_context(
db_session, db_search_space, _tool_uses_test_session, _pinned_embedding
db_session, db_workspace, _tool_uses_test_session, _pinned_embedding
):
"""When both carry pins, state wins (the forwarded subagent pin is authoritative)."""
state_doc = await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="StatePinned",
text="asyncio appears in the state-pinned doc.",
)
context_doc = await _add_document(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
title="ContextPinned",
text="asyncio appears in the context-pinned doc.",
)
tool = create_search_knowledge_base_tool(search_space_id=db_search_space.id)
tool = create_search_knowledge_base_tool(workspace_id=db_workspace.id)
result = await _invoke(
tool,

View file

@ -35,18 +35,18 @@ def _last_ai_text(messages: list) -> str | None:
@pytest.mark.asyncio
async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_space):
async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_workspace):
"""A freshly assembled agent streams a scripted final-text turn to completion."""
harness = build_scripted_harness(turns=[ScriptedTurn(text="done")])
agent = await create_multi_agent_chat_deep_agent(
llm=harness.model,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(),
user_id=str(db_user.id),
thread_id=db_search_space.id,
thread_id=db_workspace.id,
agent_config=None,
)
@ -59,7 +59,7 @@ async def test_agent_runs_a_scripted_text_turn(db_session, db_user, db_search_sp
@pytest.mark.asyncio
async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_space):
async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_workspace):
"""The compiled graph routes a model tool call to its tool and resumes."""
harness = build_scripted_harness(
turns=[
@ -79,12 +79,12 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
agent = await create_multi_agent_chat_deep_agent(
llm=harness.model,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=InMemorySaver(),
user_id=str(db_user.id),
thread_id=db_search_space.id,
thread_id=db_workspace.id,
agent_config=None,
additional_tools=harness.tools,
)
@ -101,7 +101,7 @@ async def test_agent_routes_a_scripted_tool_call(db_session, db_user, db_search_
@pytest.mark.asyncio
async def test_agent_checkpoint_round_trips_across_turns(
db_session, db_user, db_search_space
db_session, db_user, db_workspace
):
"""Turn 2 sees turn 1's history, proving the checkpoint serializes and reloads.
@ -118,12 +118,12 @@ async def test_agent_checkpoint_round_trips_across_turns(
async def _build():
return await create_multi_agent_chat_deep_agent(
llm=harness.model,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
db_session=db_session,
connector_service=ConnectorService(db_session),
checkpointer=checkpointer,
user_id=str(db_user.id),
thread_id=db_search_space.id,
thread_id=db_workspace.id,
agent_config=None,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,13 +19,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
db_session, seed_google_docs
):
"""Searching with a list of document types returns documents of ALL listed types."""
space_id = seed_google_docs["search_space"].id
space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
document_type=["GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"],
query_embedding=DUMMY_EMBEDDING,
)
@ -40,13 +40,13 @@ async def test_list_of_types_returns_both_matching_doc_types(
async def test_single_string_type_returns_only_that_type(db_session, seed_google_docs):
"""Searching with a single string type returns only documents of that exact type."""
space_id = seed_google_docs["search_space"].id
space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
document_type="GOOGLE_DRIVE_FILE",
query_embedding=DUMMY_EMBEDDING,
)
@ -59,13 +59,13 @@ async def test_single_string_type_returns_only_that_type(db_session, seed_google
async def test_all_invalid_types_returns_empty(db_session, seed_google_docs):
"""Searching with a list of nonexistent types returns an empty list, no exceptions."""
space_id = seed_google_docs["search_space"].id
space_id = seed_google_docs["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly report",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
document_type=["NONEXISTENT_TYPE"],
query_embedding=DUMMY_EMBEDDING,
)

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

View file

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

View file

@ -15,14 +15,14 @@ pytestmark = pytest.mark.integration
def _cal_doc(
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"Event {unique_id}",
source_markdown=f"## Calendar Event\n\nDetails for {unique_id}",
unique_id=unique_id,
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
search_space_id=search_space_id,
workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@ -36,13 +36,13 @@ def _cal_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A Calendar ConnectorDocument flows through prepare + index to a READY document."""
space_id = db_search_space.id
space_id = db_workspace.id
doc = _cal_doc(
unique_id="evt-1",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@ -54,7 +54,7 @@ async def test_calendar_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@ -65,10 +65,10 @@ async def test_calendar_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_calendar_legacy_doc_migrated(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A legacy Composio Calendar doc is migrated and reused."""
space_id = db_search_space.id
space_id = db_workspace.id
user_id = str(db_user.id)
evt_id = "evt-legacy-cal"
@ -82,7 +82,7 @@ async def test_calendar_legacy_doc_migrated(
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
source_markdown="## Old event",
search_space_id=space_id,
workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@ -93,7 +93,7 @@ async def test_calendar_legacy_doc_migrated(
connector_doc = _cal_doc(
unique_id=evt_id,
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)

View file

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

View file

@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _dropbox_doc(
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id,
document_type=DocumentType.DROPBOX_FILE,
search_space_id=search_space_id,
workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@ -34,13 +34,13 @@ def _dropbox_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A Dropbox ConnectorDocument flows through prepare + index to a READY document."""
space_id = db_search_space.id
space_id = db_workspace.id
doc = _dropbox_doc(
unique_id="db-file-abc",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@ -52,7 +52,7 @@ async def test_dropbox_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@ -63,15 +63,15 @@ async def test_dropbox_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_dropbox_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""Re-indexing a Dropbox doc with the same content is skipped (content hash match)."""
space_id = db_search_space.id
space_id = db_workspace.id
user_id = str(db_user.id)
doc = _dropbox_doc(
unique_id="db-dup-file",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
@ -83,13 +83,13 @@ async def test_dropbox_duplicate_content_skipped(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
first_doc = result.scalars().first()
assert first_doc is not None
doc2 = _dropbox_doc(
unique_id="db-dup-file",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)

View file

@ -17,7 +17,7 @@ pytestmark = pytest.mark.integration
def _gmail_doc(
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
"""Build a Gmail-style ConnectorDocument like the real indexer does."""
return ConnectorDocument(
@ -25,7 +25,7 @@ def _gmail_doc(
source_markdown=f"## Email\n\nBody of {unique_id}",
unique_id=unique_id,
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
search_space_id=search_space_id,
workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@ -38,13 +38,13 @@ def _gmail_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A Gmail ConnectorDocument flows through prepare + index to a READY document."""
space_id = db_search_space.id
space_id = db_workspace.id
doc = _gmail_doc(
unique_id="msg-pipeline-1",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@ -56,7 +56,7 @@ async def test_gmail_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@ -68,10 +68,10 @@ async def test_gmail_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_gmail_legacy_doc_migrated_then_reused(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A legacy Composio Gmail doc is migrated then reused by the pipeline."""
space_id = db_search_space.id
space_id = db_workspace.id
user_id = str(db_user.id)
msg_id = "msg-legacy-gmail"
@ -85,7 +85,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
source_markdown="## Old content",
search_space_id=space_id,
workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@ -96,7 +96,7 @@ async def test_gmail_legacy_doc_migrated_then_reused(
connector_doc = _gmail_doc(
unique_id=msg_id,
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,10 +14,10 @@ pytestmark = pytest.mark.integration
async def test_legacy_composio_gmail_doc_migrated_in_db(
db_session, db_search_space, db_user, make_connector_document
db_session, db_workspace, db_user, make_connector_document
):
"""A Composio Gmail doc in the DB gets its hash and type updated to native."""
space_id = db_search_space.id
space_id = db_workspace.id
user_id = str(db_user.id)
unique_id = "msg-legacy-123"
@ -34,7 +34,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
content="legacy content",
content_hash=f"ch-{legacy_hash[:12]}",
unique_identifier_hash=legacy_hash,
search_space_id=space_id,
workspace_id=space_id,
created_by_id=user_id,
embedding=[0.1] * _EMBEDDING_DIM,
status={"state": "ready"},
@ -46,7 +46,7 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id=unique_id,
search_space_id=space_id,
workspace_id=space_id,
)
service = IndexingPipelineService(session=db_session)
@ -60,32 +60,32 @@ async def test_legacy_composio_gmail_doc_migrated_in_db(
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."""
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_CALENDAR_CONNECTOR,
unique_id="evt-no-legacy",
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
)
service = IndexingPipelineService(session=db_session)
await service.migrate_legacy_docs([connector_doc])
result = await db_session.execute(
select(Document).filter(Document.search_space_id == db_search_space.id)
select(Document).filter(Document.workspace_id == db_workspace.id)
)
assert result.scalars().all() == []
async def test_non_google_type_is_skipped(
db_session, db_search_space, make_connector_document
db_session, db_workspace, make_connector_document
):
"""migrate_legacy_docs skips ConnectorDocuments that are not Google types."""
connector_doc = make_connector_document(
document_type=DocumentType.CLICKUP_CONNECTOR,
unique_id="task-1",
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
)
service = IndexingPipelineService(session=db_session)

View file

@ -14,14 +14,14 @@ pytestmark = pytest.mark.integration
def _onedrive_doc(
*, unique_id: str, search_space_id: int, connector_id: int, user_id: str
*, unique_id: str, workspace_id: int, connector_id: int, user_id: str
) -> ConnectorDocument:
return ConnectorDocument(
title=f"File {unique_id}.docx",
source_markdown=f"## Document\n\nContent from {unique_id}",
unique_id=unique_id,
document_type=DocumentType.ONEDRIVE_FILE,
search_space_id=search_space_id,
workspace_id=workspace_id,
connector_id=connector_id,
created_by_id=user_id,
metadata={
@ -34,13 +34,13 @@ def _onedrive_doc(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""A OneDrive ConnectorDocument flows through prepare + index to a READY document."""
space_id = db_search_space.id
space_id = db_workspace.id
doc = _onedrive_doc(
unique_id="od-file-abc",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=str(db_user.id),
)
@ -52,7 +52,7 @@ async def test_onedrive_pipeline_creates_ready_document(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
row = result.scalars().first()
@ -63,15 +63,15 @@ async def test_onedrive_pipeline_creates_ready_document(
@pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
async def test_onedrive_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker
db_session, db_workspace, db_connector, db_user, mocker
):
"""Re-indexing a OneDrive doc with the same content is skipped (content hash match)."""
space_id = db_search_space.id
space_id = db_workspace.id
user_id = str(db_user.id)
doc = _onedrive_doc(
unique_id="od-dup-file",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)
@ -83,13 +83,13 @@ async def test_onedrive_duplicate_content_skipped(
await service.index(prepared[0], doc)
result = await db_session.execute(
select(Document).filter(Document.search_space_id == space_id)
select(Document).filter(Document.workspace_id == space_id)
)
first_doc = result.scalars().first()
assert first_doc is not None
doc2 = _onedrive_doc(
unique_id="od-dup-file",
search_space_id=space_id,
workspace_id=space_id,
connector_id=db_connector.id,
user_id=user_id,
)

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User
from app.db import Workspace, User
from app.notifications.service import NotificationService
pytestmark = pytest.mark.integration
@ -13,22 +13,22 @@ pytestmark = pytest.mark.integration
handler = NotificationService.document_processing
async def _started(db_session, db_user, db_search_space, *, name="report.pdf"):
async def _started(db_session, db_user, db_workspace, *, name="report.pdf"):
"""Open a document-processing notification to update in the tests below."""
return await handler.notify_processing_started(
session=db_session,
user_id=db_user.id,
document_type="FILE",
document_name=name,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
)
async def test_processing_started_queues(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Starting processing queues a notification in the 'queued' stage."""
notification = await _started(db_session, db_user, db_search_space)
notification = await _started(db_session, db_user, db_workspace)
assert notification.type == "document_processing"
assert notification.title == "Processing: report.pdf"
@ -37,10 +37,10 @@ async def test_processing_started_queues(
async def test_processing_progress_maps_stage(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A progress update maps the stage to its user-facing message."""
notification = await _started(db_session, db_user, db_search_space)
notification = await _started(db_session, db_user, db_workspace)
updated = await handler.notify_processing_progress(
session=db_session, notification=notification, stage="parsing"
@ -51,10 +51,10 @@ async def test_processing_progress_maps_stage(
async def test_processing_completed_success(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Successful processing reports ready/searchable and a completed status."""
notification = await _started(db_session, db_user, db_search_space)
notification = await _started(db_session, db_user, db_workspace)
done = await handler.notify_processing_completed(
session=db_session, notification=notification, document_id=99
@ -66,10 +66,10 @@ async def test_processing_completed_success(
async def test_processing_completed_failure(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""Failed processing reports a failed status with the error in the message."""
notification = await _started(db_session, db_user, db_search_space)
notification = await _started(db_session, db_user, db_workspace)
done = await handler.notify_processing_completed(
session=db_session, notification=notification, error_message="bad file"
@ -81,7 +81,7 @@ async def test_processing_completed_failure(
async def test_processing_started_truncates_long_filename(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
db_session: AsyncSession, db_user: User, db_workspace: Workspace
):
"""A long filename is truncated in the title but kept in metadata."""
long_name = "a" * 250
@ -91,7 +91,7 @@ async def test_processing_started_truncates_long_filename(
user_id=db_user.id,
document_type="FILE",
document_name=long_name,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
)
assert len(notification.title) <= 200

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,9 +15,9 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
async def test_cancel_from_a_live_state_succeeds(client, db_workspace, make_podcast):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
@ -27,10 +27,10 @@ async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_p
async def test_cancel_from_a_terminal_state_conflicts(
client, db_search_space, make_podcast
client, db_workspace, make_podcast
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
@ -39,12 +39,12 @@ async def test_cancel_from_a_terminal_state_conflicts(
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
# regeneration is the way back.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")

View file

@ -14,12 +14,12 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
async def test_create_proposes_brief_and_opens_gate(client, db_workspace):
resp = await client.post(
BASE,
json={
"title": "My Episode",
"search_space_id": db_search_space.id,
"workspace_id": db_workspace.id,
"source_content": "A long piece of source content about a topic.",
},
)
@ -36,12 +36,12 @@ async def test_create_proposes_brief_and_opens_gate(client, db_search_space):
assert body["has_audio"] is False
async def test_create_honors_requested_speaker_count(client, db_search_space):
async def test_create_honors_requested_speaker_count(client, db_workspace):
resp = await client.post(
BASE,
json={
"title": "Solo",
"search_space_id": db_search_space.id,
"workspace_id": db_workspace.id,
"source_content": "Content.",
"speaker_count": 3,
},

View file

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

View file

@ -12,9 +12,9 @@ from app.db import NewChatThread, PublicChatSnapshot, User
pytestmark = pytest.mark.integration
async def _snapshot(db_session, *, search_space_id, user: User, token: str, podcasts):
async def _snapshot(db_session, *, workspace_id, user: User, token: str, podcasts):
thread = NewChatThread(
title="Shared", search_space_id=search_space_id, created_by_id=user.id
title="Shared", workspace_id=workspace_id, created_by_id=user.id
)
db_session.add(thread)
await db_session.flush()
@ -30,11 +30,11 @@ async def _snapshot(db_session, *, search_space_id, user: User, token: str, podc
async def test_public_stream_serves_audio_via_storage_key(
client, db_session, db_search_space, db_user, fake_storage
client, db_session, db_workspace, db_user, fake_storage
):
await _snapshot(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
user=db_user,
token="tok-audio",
podcasts=[{"original_id": 555, "storage_key": "podcasts/x.mp3"}],
@ -49,11 +49,11 @@ async def test_public_stream_serves_audio_via_storage_key(
async def test_public_stream_404_when_object_missing(
client, db_session, db_search_space, db_user, fake_storage
client, db_session, db_workspace, db_user, fake_storage
):
await _snapshot(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
user=db_user,
token="tok-gone",
podcasts=[{"original_id": 556, "storage_key": "podcasts/gone.mp3"}],
@ -65,11 +65,11 @@ async def test_public_stream_404_when_object_missing(
async def test_public_stream_404_when_podcast_absent_from_snapshot(
client, db_session, db_search_space, db_user
client, db_session, db_workspace, db_user
):
await _snapshot(
db_session,
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
user=db_user,
token="tok-empty",
podcasts=[],

View file

@ -24,10 +24,10 @@ BASE = "/api/v1/podcasts"
async def test_regenerate_from_ready_reopens_the_brief_gate(
client, db_search_space, make_podcast, captured_tasks
client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@ -43,10 +43,10 @@ async def test_regenerate_from_ready_reopens_the_brief_gate(
async def test_approving_the_reopened_brief_starts_a_fresh_draft(
client, db_search_space, make_podcast, captured_tasks
client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@ -54,15 +54,15 @@ async def test_approving_the_reopened_brief_starts_a_fresh_draft(
assert resp.status_code == 200
assert resp.json()["status"] == "drafting"
assert captured_tasks.draft == [((podcast.id, db_search_space.id), {})]
assert captured_tasks.draft == [((podcast.id, db_workspace.id), {})]
async def test_regenerate_from_brief_gate_is_rejected(
client, db_search_space, make_podcast, captured_tasks
client, db_workspace, make_podcast, captured_tasks
):
# Nothing has been drafted yet, so there is nothing to regenerate.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@ -72,10 +72,10 @@ async def test_regenerate_from_brief_gate_is_rejected(
async def test_regenerate_from_cancelled_is_rejected(
client, db_search_space, make_podcast, captured_tasks
client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
await client.post(f"{BASE}/{podcast.id}/cancel")
@ -86,10 +86,10 @@ async def test_regenerate_from_cancelled_is_rejected(
async def test_reverting_a_regeneration_restores_the_ready_episode(
client, db_search_space, make_podcast, captured_tasks
client, db_workspace, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
@ -105,12 +105,12 @@ async def test_reverting_a_regeneration_restores_the_ready_episode(
async def test_reverting_mid_draft_keeps_the_episode(
client, db_search_space, make_podcast
client, db_workspace, make_podcast
):
# Changing one's mind is allowed even after the reopened brief was
# approved: the episode survives until a new render replaces it.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/brief/approve")
@ -122,10 +122,10 @@ async def test_reverting_mid_draft_keeps_the_episode(
async def test_reverting_mid_render_keeps_the_episode(
client, db_session, db_search_space, make_podcast
client, db_session, db_workspace, make_podcast
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
service = PodcastService(db_session)
await service.regenerate(podcast)
@ -139,12 +139,12 @@ async def test_reverting_mid_render_keeps_the_episode(
async def test_reverted_episode_can_be_regenerated_again(
client, db_search_space, make_podcast
client, db_workspace, make_podcast
):
# Reverting must not strand the episode: the user can change their mind
# again immediately.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@ -156,11 +156,11 @@ async def test_reverted_episode_can_be_regenerated_again(
async def test_revert_on_a_fresh_brief_gate_is_rejected(
client, db_search_space, make_podcast
client, db_workspace, make_podcast
):
# A first-time brief has no regeneration to revert.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
workspace_id=db_workspace.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@ -170,10 +170,10 @@ async def test_revert_on_a_fresh_brief_gate_is_rejected(
async def test_revert_when_nothing_was_regenerated_is_rejected(
client, db_search_space, make_podcast
client, db_workspace, make_podcast
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
@ -182,13 +182,13 @@ async def test_revert_when_nothing_was_regenerated_is_rejected(
async def test_regenerate_without_a_brief_is_rejected(
client, db_session, db_search_space, captured_tasks
client, db_session, db_workspace, captured_tasks
):
# Legacy episodes finished before briefs existed; reopening a gate with
# nothing to review would strand them there.
podcast = Podcast(
title="Legacy Episode",
search_space_id=db_search_space.id,
workspace_id=db_workspace.id,
status=PodcastStatus.READY,
spec_version=1,
file_location="/var/old/podcast.mp3",

View file

@ -20,10 +20,10 @@ pytestmark = pytest.mark.integration
async def test_render_marks_ready_and_stores_audio(
db_search_space, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
db_workspace, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.RENDERING
workspace_id=db_workspace.id, status=PodcastStatus.RENDERING
)
result = await render._render_audio(podcast.id)
@ -37,7 +37,7 @@ async def test_render_marks_ready_and_stores_audio(
async def test_rerender_replaces_audio_and_purges_the_old_object(
db_session,
db_search_space,
db_workspace,
make_podcast,
bind_task_session,
fake_tts,
@ -47,7 +47,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
# A regenerated episode keeps exactly one stored object: the new render
# must not leak the superseded audio in the object store.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio"
@ -68,7 +68,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
db_session,
db_search_space,
db_workspace,
make_podcast,
bind_task_session,
fake_tts,
@ -79,7 +79,7 @@ async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothin
# stale render must neither resurrect the redo nor leak the object it
# already stored.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio"

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
unscoped listing returns only the caller's own podcasts — never another
@ -13,9 +13,9 @@ BASE = "/api/v1/podcasts"
async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
client, db_search_space, make_podcast, act_as, db_other_user
client, db_workspace, make_podcast, act_as, db_other_user
):
podcast = await make_podcast(search_space_id=db_search_space.id)
podcast = await make_podcast(workspace_id=db_workspace.id)
act_as(db_other_user)
resp = await client.get(f"{BASE}/{podcast.id}")
@ -24,7 +24,7 @@ async def test_reading_a_podcast_in_a_nonmember_space_is_forbidden(
async def test_creating_in_a_nonmember_space_is_forbidden(
client, db_search_space, act_as, db_other_user
client, db_workspace, act_as, db_other_user
):
act_as(db_other_user)
@ -32,7 +32,7 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
BASE,
json={
"title": "X",
"search_space_id": db_search_space.id,
"workspace_id": db_workspace.id,
"source_content": "content",
},
)
@ -41,9 +41,9 @@ async def test_creating_in_a_nonmember_space_is_forbidden(
async def test_listing_returns_only_the_callers_podcasts(
client, db_search_space, make_podcast, foreign_podcast
client, db_workspace, make_podcast, foreign_podcast
):
mine = await make_podcast(search_space_id=db_search_space.id, title="Mine")
mine = await make_podcast(workspace_id=db_workspace.id, title="Mine")
resp = await client.get(BASE)

View file

@ -16,10 +16,10 @@ BASE = "/api/v1/podcasts"
async def test_stream_serves_stored_audio(
client, db_search_space, make_podcast, fake_storage
client, db_workspace, make_podcast, fake_storage
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
fake_storage.objects["podcasts/audio.mp3"] = b"the-audio"
@ -30,9 +30,9 @@ async def test_stream_serves_stored_audio(
assert resp.content == b"the-audio"
async def test_stream_409_while_in_flight(client, db_search_space, make_podcast):
async def test_stream_409_while_in_flight(client, db_workspace, make_podcast):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
resp = await client.get(f"{BASE}/{podcast.id}/stream")
@ -41,10 +41,10 @@ async def test_stream_409_while_in_flight(client, db_search_space, make_podcast)
async def test_stream_404_when_object_missing(
client, db_search_space, make_podcast, fake_storage
client, db_workspace, make_podcast, fake_storage
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
resp = await client.get(f"{BASE}/{podcast.id}/stream")

View file

@ -17,10 +17,10 @@ pytestmark = pytest.mark.integration
async def test_marking_failed_records_the_reason_on_a_running_podcast(
db_search_space, make_podcast, bind_task_session
db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
workspace_id=db_workspace.id, status=PodcastStatus.DRAFTING
)
await runtime.mark_failed(podcast.id, "tts provider unavailable")
@ -30,10 +30,10 @@ async def test_marking_failed_records_the_reason_on_a_running_podcast(
async def test_marking_failed_leaves_an_already_terminal_podcast_untouched(
db_search_space, make_podcast, bind_task_session
db_workspace, make_podcast, bind_task_session
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
workspace_id=db_workspace.id, status=PodcastStatus.READY
)
await runtime.mark_failed(podcast.id, "too late")

View file

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

View file

@ -18,13 +18,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -40,13 +40,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
"""Document metadata (title, type, etc.) should be present even without joinedload."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -62,13 +62,13 @@ async def test_doc_metadata_populated_from_rrf(db_session, seed_large_doc):
async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
"""matched_chunk_ids should contain the chunk IDs that appeared in the RRF results."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -83,13 +83,13 @@ async def test_matched_chunk_ids_tracked(db_session, seed_large_doc):
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID (original order)."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -100,13 +100,13 @@ async def test_chunks_ordered_by_id(db_session, seed_large_doc):
async def test_score_is_positive_float(db_session, seed_large_doc):
"""Each result should have a positive float score from RRF."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = ChucksHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)

View file

@ -17,13 +17,13 @@ pytestmark = pytest.mark.integration
async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
"""A document with 35 chunks should have at most _MAX_FETCH_CHUNKS_PER_DOC chunks returned."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -39,13 +39,13 @@ async def test_per_doc_chunk_limit_respected(db_session, seed_large_doc):
async def test_doc_metadata_populated(db_session, seed_large_doc):
"""Document metadata should be present from the RRF results."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)
@ -61,13 +61,13 @@ async def test_doc_metadata_populated(db_session, seed_large_doc):
async def test_chunks_ordered_by_id(db_session, seed_large_doc):
"""Chunks within each document should be ordered by chunk ID."""
space_id = seed_large_doc["search_space"].id
space_id = seed_large_doc["workspace"].id
retriever = DocumentHybridSearchRetriever(db_session)
results = await retriever.hybrid_search(
query_text="quarterly performance review",
top_k=10,
search_space_id=space_id,
workspace_id=space_id,
query_embedding=DUMMY_EMBEDDING,
)

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

View file

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

View file

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

View file

@ -7,9 +7,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import PersonalAccessToken, SearchSpace, User
from app.db import PersonalAccessToken, Workspace, User
from app.users import allow_any_principal, require_session_context
from app.utils.rbac import check_search_space_access
from app.utils.rbac import check_workspace_access
pytestmark = pytest.mark.integration
@ -43,29 +43,29 @@ async def test_pat_is_allowed_by_bootstrap_dependency(db_user: User):
async def test_pat_is_rejected_for_api_disabled_space(
db_session: AsyncSession,
db_user: User,
db_search_space: SearchSpace,
db_workspace: Workspace,
):
db_search_space.api_access_enabled = False
db_workspace.api_access_enabled = False
await db_session.flush()
auth = _pat_auth(db_user)
with pytest.raises(HTTPException) as exc_info:
await check_search_space_access(db_session, auth, db_search_space.id)
await check_workspace_access(db_session, auth, db_workspace.id)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "API access is not enabled for this search space."
assert exc_info.value.detail == "API access is not enabled for this workspace."
async def test_pat_is_allowed_for_api_enabled_space(
db_session: AsyncSession,
db_user: User,
db_search_space: SearchSpace,
db_workspace: Workspace,
):
db_search_space.api_access_enabled = True
db_workspace.api_access_enabled = True
await db_session.flush()
auth = _pat_auth(db_user)
membership = await check_search_space_access(db_session, auth, db_search_space.id)
membership = await check_workspace_access(db_session, auth, db_workspace.id)
assert membership.user_id == db_user.id
assert membership.search_space_id == db_search_space.id
assert membership.workspace_id == db_workspace.id

View file

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