chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
View File
+435
View File
@@ -0,0 +1,435 @@
"""Tests for application/api/user/sources/chunks.py."""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.chunks.db_readonly", _yield
):
yield
def _seed_source(pg_conn, user="u", name="src"):
from application.storage.db.repositories.sources import SourcesRepository
return SourcesRepository(pg_conn).create(name, user_id=user)
class TestResolveSource:
def test_returns_none_for_missing(self, pg_conn):
from application.api.user.sources.chunks import _resolve_source
with _patch_db(pg_conn):
assert (
_resolve_source(
"00000000-0000-0000-0000-000000000000", "u"
)
is None
)
def test_returns_source_when_found(self, pg_conn):
from application.api.user.sources.chunks import _resolve_source
src = _seed_source(pg_conn, user="u-resolve")
with _patch_db(pg_conn):
got = _resolve_source(str(src["id"]), "u-resolve")
assert got is not None
assert str(got["id"]) == str(src["id"])
class TestGetChunks:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.chunks import GetChunks
with app.test_request_context("/api/get_chunks?id=abc"):
from flask import request
request.decoded_token = None
response = GetChunks().get()
assert response.status_code == 401
def test_returns_400_missing_id(self, app):
from application.api.user.sources.chunks import GetChunks
with app.test_request_context("/api/get_chunks"):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetChunks().get()
assert response.status_code == 400
def test_returns_400_on_resolve_error(self, app):
from application.api.user.sources.chunks import GetChunks
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.sources.chunks.db_readonly", _broken
), app.test_request_context("/api/get_chunks?id=abc"):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetChunks().get()
assert response.status_code == 400
def test_returns_404_when_source_missing(self, app, pg_conn):
from application.api.user.sources.chunks import GetChunks
with _patch_db(pg_conn), app.test_request_context(
"/api/get_chunks?id=00000000-0000-0000-0000-000000000000"
):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetChunks().get()
assert response.status_code == 404
def test_returns_paginated_chunks(self, app, pg_conn):
from application.api.user.sources.chunks import GetChunks
user = "u-chunks"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"text": f"chunk {i}", "metadata": {"title": f"T{i}"}}
for i in range(5)
]
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/get_chunks?id={src['id']}&per_page=2&page=1"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetChunks().get()
assert response.status_code == 200
data = response.json
assert data["total"] == 5
assert len(data["chunks"]) == 2
def test_filters_by_path(self, app, pg_conn):
from application.api.user.sources.chunks import GetChunks
user = "u-path"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"text": "a", "metadata": {"source": "/a/b/file.txt"}},
{"text": "b", "metadata": {"source": "/other.txt"}},
]
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/get_chunks?id={src['id']}&path=b/file.txt"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetChunks().get()
assert response.status_code == 200
assert response.json["total"] == 1
def test_filters_by_search(self, app, pg_conn):
from application.api.user.sources.chunks import GetChunks
user = "u-srch"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"text": "the cat", "metadata": {"title": ""}},
{"text": "a dog", "metadata": {"title": ""}},
]
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/get_chunks?id={src['id']}&search=cat"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetChunks().get()
assert response.status_code == 200
assert response.json["total"] == 1
def test_returns_500_on_vector_store_error(self, app, pg_conn):
from application.api.user.sources.chunks import GetChunks
user = "u-err"
src = _seed_source(pg_conn, user=user)
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
side_effect=RuntimeError("boom"),
), app.test_request_context(f"/api/get_chunks?id={src['id']}"):
from flask import request
request.decoded_token = {"sub": user}
response = GetChunks().get()
assert response.status_code == 500
class TestAddChunk:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.chunks import AddChunk
with app.test_request_context(
"/api/add_chunk", method="POST",
json={"id": "x", "text": "hello"},
):
from flask import request
request.decoded_token = None
response = AddChunk().post()
assert response.status_code == 401
def test_returns_400_missing_fields(self, app):
from application.api.user.sources.chunks import AddChunk
with app.test_request_context(
"/api/add_chunk", method="POST", json={"id": "x"}
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
from application.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
"/api/add_chunk", method="POST",
json={
"id": "00000000-0000-0000-0000-000000000000",
"text": "content",
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 403
def test_adds_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import AddChunk
user = "u-add"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.add_chunk.return_value = "chunk-id-1"
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
"/api/add_chunk", method="POST",
json={
"id": str(src["id"]),
"text": "the text of the chunk",
"metadata": {"title": "My Chunk"},
},
):
from flask import request
request.decoded_token = {"sub": user}
response = AddChunk().post()
assert response.status_code == 201
assert response.json["chunk_id"] == "chunk-id-1"
def test_returns_500_on_vector_error(self, app, pg_conn):
from application.api.user.sources.chunks import AddChunk
user = "u-adderr"
src = _seed_source(pg_conn, user=user)
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
side_effect=RuntimeError("bad"),
), app.test_request_context(
"/api/add_chunk", method="POST",
json={"id": str(src["id"]), "text": "x"},
):
from flask import request
request.decoded_token = {"sub": user}
response = AddChunk().post()
assert response.status_code == 500
class TestDeleteChunk:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.chunks import DeleteChunk
with app.test_request_context(
"/api/delete_chunk?id=x&chunk_id=y", method="DELETE"
):
from flask import request
request.decoded_token = None
response = DeleteChunk().delete()
assert response.status_code == 401
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
from application.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
"/api/delete_chunk?id=00000000-0000-0000-0000-000000000000&chunk_id=c",
method="DELETE",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
assert response.status_code == 403
def test_deletes_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import DeleteChunk
user = "u-del"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.delete_chunk.return_value = True
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/delete_chunk?id={src['id']}&chunk_id=c", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": user}
response = DeleteChunk().delete()
assert response.status_code == 200
def test_returns_404_chunk_not_found(self, app, pg_conn):
from application.api.user.sources.chunks import DeleteChunk
user = "u-missing-chunk"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.delete_chunk.return_value = False
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/delete_chunk?id={src['id']}&chunk_id=c", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": user}
response = DeleteChunk().delete()
assert response.status_code == 404
class TestUpdateChunk:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.chunks import UpdateChunk
with app.test_request_context(
"/api/update_chunk", method="PUT",
json={"id": "x", "chunk_id": "c"},
):
from flask import request
request.decoded_token = None
response = UpdateChunk().put()
assert response.status_code == 401
def test_returns_400_missing_fields(self, app):
from application.api.user.sources.chunks import UpdateChunk
with app.test_request_context(
"/api/update_chunk", method="PUT", json={"id": "x"}
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
from application.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
"/api/update_chunk", method="PUT",
json={
"id": "00000000-0000-0000-0000-000000000000",
"chunk_id": "c",
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 403
def test_returns_404_chunk_not_found(self, app, pg_conn):
from application.api.user.sources.chunks import UpdateChunk
user = "u-upd-missing"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = []
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
"/api/update_chunk", method="PUT",
json={"id": str(src["id"]), "chunk_id": "missing"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateChunk().put()
assert response.status_code == 404
def test_updates_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import UpdateChunk
user = "u-upd"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{
"doc_id": "chunk-123",
"text": "old",
"metadata": {"title": "T"},
}
]
fake_store.add_chunk.return_value = "new-chunk-id"
fake_store.delete_chunk.return_value = True
with _patch_db(pg_conn), patch(
"application.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
"/api/update_chunk", method="PUT",
json={
"id": str(src["id"]),
"chunk_id": "chunk-123",
"text": "new text",
},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateChunk().put()
assert response.status_code == 200
assert response.json["chunk_id"] == "new-chunk-id"
+269
View File
@@ -0,0 +1,269 @@
"""Tests for the GraphRAG graph-view routes in
application/api/user/sources/routes.py.
The endpoints are read-access gated (owner or team grant). The ``GraphStore`` is
mocked so no live vector store, embeddings, or LLM calls run; the ``sources`` row
is real so the authz lookup resolves. A separate suite exercises
``GraphStore.get_graph_overview`` against a live pgvector store (skipped when
unreachable) and asserts the SQL is parameterized via a mock cursor.
"""
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from application.storage.db.repositories.sources import SourcesRepository
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.routes.db_session", _yield
), patch(
"application.api.user.sources.routes.db_readonly", _yield
):
yield
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], member, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
access_level=access_level,
)
def _graphrag_source(pg_conn, user):
src = SourcesRepository(pg_conn).create(
"graph-src", user_id=user, type="file",
config={
"kind": "graphrag",
"retrieval": {"retriever": "graphrag"},
},
directory_structure={"a.md": {"type": "text/markdown"}},
)
return str(src["id"])
@pytest.mark.unit
class TestSourceGraph:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import SourceGraph
with app.test_request_context("/api/sources/x/graph"):
from flask import request
request.decoded_token = None
response = SourceGraph().get("x")
assert response.status_code == 401
def test_owner_gets_bounded_overview(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
user = "u-graph-view-owner"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.count_nodes.return_value = 3
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": "person",
"description": "d", "degree": 2},
{"id": "n2", "name": "B", "type": "org",
"description": "e", "degree": 1},
],
"edges": [
{"source": "n1", "target": "n2", "type": "rel", "weight": 1.0},
],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(
f"/api/sources/{sid}/graph?limit=9999"
):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert response.json["success"] is True
assert {n["id"] for n in response.json["nodes"]} == {"n1", "n2"}
assert response.json["edges"][0]["source"] == "n1"
# The store receives the source's resolved id and the clamped limit.
args = store.get_graph_overview.call_args.args
assert args[0] == sid
# The route forwards the raw limit; clamping is the store's job (tested
# below) but a sane request limit must reach it.
assert args[1] == 9999
def test_empty_graph_returns_empty_lists(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
user = "u-graph-view-empty"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.count_nodes.return_value = 0
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert response.json["nodes"] == []
assert response.json["edges"] == []
# No graph rows → never query the overview.
store.get_graph_overview.assert_not_called()
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
owner = "u-graph-view-owner2"
stranger = "u-graph-view-stranger"
sid = _graphrag_source(pg_conn, owner)
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore"
) as mock_store, app.test_request_context(
f"/api/sources/{sid}/graph"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = SourceGraph().get(sid)
assert response.status_code == 404
mock_store.assert_not_called()
def test_team_viewer_can_read(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
owner = "alice-graph-view"
viewer = "bob-graph-view-viewer"
sid = _graphrag_source(pg_conn, owner)
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
store = MagicMock()
store.count_nodes.return_value = 1
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": None,
"description": None, "degree": 0},
],
"edges": [],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph"):
from flask import request
request.decoded_token = {"sub": viewer}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert {n["id"] for n in response.json["nodes"]} == {"n1"}
@pytest.mark.unit
class TestSourceGraphNode:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import SourceGraphNode
with app.test_request_context("/api/sources/x/graph/node/n"):
from flask import request
request.decoded_token = None
response = SourceGraphNode().get("x", "n")
assert response.status_code == 401
def test_owner_gets_node_detail_with_chunks(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
user = "u-graph-node-owner"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.get_node_detail.return_value = {
"id": "n1",
"name": "Ada",
"type": "person",
"description": "A mathematician.",
"degree": 3,
"doc_freq": 2,
"chunks": [{"chunk_id": "5", "text": "body", "metadata": {}}],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/node/n1"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNode().get(sid, "n1")
assert response.status_code == 200
assert response.json["node"]["name"] == "Ada"
assert response.json["node"]["chunks"][0]["text"] == "body"
store.get_node_detail.assert_called_once_with(sid, "n1")
def test_unknown_node_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
user = "u-graph-node-missing"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.get_node_detail.return_value = None
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/node/nope"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNode().get(sid, "nope")
assert response.status_code == 404
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
owner = "u-graph-node-owner2"
stranger = "u-graph-node-stranger"
sid = _graphrag_source(pg_conn, owner)
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore"
) as mock_store, app.test_request_context(
f"/api/sources/{sid}/graph/node/n1"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = SourceGraphNode().get(sid, "n1")
assert response.status_code == 404
mock_store.assert_not_called()
@@ -0,0 +1,200 @@
"""Tests for the GraphRAG enable route in application/api/user/sources/routes.py.
``graphrag_available`` and ``extract_graph.delay`` are mocked so no live
vector store, LLM, or model calls run; the ``sources`` row is real so the
authz lookup and config write read back.
"""
import uuid
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from flask import Flask
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.source_config import SourceConfig
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.routes.db_session", _yield
), patch(
"application.api.user.sources.routes.db_readonly", _yield
):
yield
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], member, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
access_level=access_level,
)
@pytest.mark.unit
class TestEnableSourceGraphRAG:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import EnableSourceGraphRAG
with app.test_request_context(
"/api/sources/x/graphrag/enable", method="POST"
):
from flask import request
request.decoded_token = None
response = EnableSourceGraphRAG().post("x")
assert response.status_code == 401
def test_unavailable_returns_400(self, app, pg_conn):
from application.api.user.sources.routes import EnableSourceGraphRAG
user = "u-graph-unavail"
src = SourcesRepository(pg_conn).create(
"files", user_id=user, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.graphrag_available",
return_value=False,
), patch(
"application.api.user.sources.routes.extract_graph.delay"
) as mock_extract, app.test_request_context(
f"/api/sources/{sid}/graphrag/enable", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
response = EnableSourceGraphRAG().post(sid)
assert response.status_code == 400
mock_extract.assert_not_called()
got = SourcesRepository(pg_conn).get_any(sid, user)
assert SourceConfig.parse(got.get("config")).kind == "classic"
def test_owner_sets_config_and_enqueues(self, app, pg_conn):
from application.api.user.sources.routes import EnableSourceGraphRAG
user = "u-graph-owner"
src = SourcesRepository(pg_conn).create(
"files", user_id=user, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
fake_task = type("T", (), {"id": "task-g"})()
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.graphrag_available",
return_value=True,
), patch(
"application.worker._reset_graph_for_source",
) as mock_reset, patch(
"application.api.user.sources.routes.extract_graph.delay",
return_value=fake_task,
) as mock_extract, app.test_request_context(
f"/api/sources/{sid}/graphrag/enable", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
response = EnableSourceGraphRAG().post(sid)
assert response.status_code == 200
assert response.json["success"] is True
assert response.json["task_id"] == "task-g"
got = SourcesRepository(pg_conn).get_any(sid, user)
cfg = SourceConfig.parse(got.get("config"))
assert cfg.kind == "graphrag"
assert cfg.retrieval.retriever == "graphrag"
# Each enable wipes any prior graph so it rebuilds from scratch.
mock_reset.assert_called_once_with(sid)
mock_extract.assert_called_once()
assert mock_extract.call_args.args[0] == sid
assert mock_extract.call_args.args[1] == user
# The key varies with the fresh ``updated_at`` the config write bumped,
# so each enable produces a new key that re-runs the worker.
key = mock_extract.call_args.kwargs["idempotency_key"]
assert key.startswith(f"extract-graph:{sid}:")
assert key != f"extract-graph:{sid}:"
def test_viewer_rejected_403(self, app, pg_conn):
from application.api.user.sources.routes import EnableSourceGraphRAG
owner = "alice-graph"
viewer = "bob-graph-viewer"
src = SourcesRepository(pg_conn).create(
"files", user_id=owner, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.graphrag_available",
return_value=True,
), patch(
"application.api.user.sources.routes.extract_graph.delay"
) as mock_extract, app.test_request_context(
f"/api/sources/{sid}/graphrag/enable", method="POST"
):
from flask import request
request.decoded_token = {"sub": viewer}
response = EnableSourceGraphRAG().post(sid)
assert response.status_code == 403
mock_extract.assert_not_called()
got = SourcesRepository(pg_conn).get_any(sid, owner)
assert SourceConfig.parse(got.get("config")).kind == "classic"
@pytest.mark.unit
class TestConfigPatchCannotSetGraphrag:
"""The config PATCH endpoint must not flip kind to graphrag (D28)."""
def test_patch_kind_graphrag_rejected_400(self, app, pg_conn):
from application.api.user.sources.routes import SourceConfigResource
user = "u-patch-graph"
src = SourcesRepository(pg_conn).create(
"files", user_id=user, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/config",
method="PATCH",
json={"kind": "graphrag"},
):
from flask import request
request.decoded_token = {"sub": user}
response = SourceConfigResource().patch(sid)
assert response.status_code == 400
got = SourcesRepository(pg_conn).get_any(sid, user)
assert SourceConfig.parse(got.get("config")).kind == "classic"
@@ -0,0 +1,97 @@
"""GET /api/sources/paginated surfaces team-shared sources (with ownership/access).
Regression test: the paginated settings list must include sources shared with the
caller's teams — matching the /api/sources dropdown — not just owned sources.
"""
from __future__ import annotations
import json
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _source_row(sid, *, user_id):
return {
"id": sid,
"name": f"src-{sid[:4]}",
"user_id": user_id,
"date": "",
"tokens": "",
"retriever": "classic",
"sync_frequency": "",
"remote_data": None,
"directory_structure": None,
"type": "file",
"ingest_status": None,
}
def _run(sub, repo, team_shared, client):
patches = [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.sources.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.sources.routes.SourcesRepository", return_value=repo),
patch(
"application.api.user.sources.routes.visible_with_access",
return_value=team_shared,
),
]
for p in patches:
p.start()
try:
return client.get("/api/sources/paginated")
finally:
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestPaginatedSourcesTeamSharing:
def test_team_shared_source_appears_with_access(self, client):
owned, shared = str(uuid.uuid4()), str(uuid.uuid4())
repo = Mock()
repo.count_for_user.return_value = 2
repo.list_for_user.return_value = [
_source_row(owned, user_id="bob"),
_source_row(shared, user_id="alice"), # not the caller → team-shared
]
resp = _run("bob", repo, {shared: "editor"}, client)
assert resp.status_code == 200
by_id = {d["id"]: d for d in json.loads(resp.data)["paginated"]}
assert by_id[owned]["ownership"] == "user"
assert by_id[owned]["team_access"] is None
assert by_id[shared]["ownership"] == "team"
assert by_id[shared]["team_access"] == "editor"
# The shared ids are unioned into the owner-scoped queries by id.
assert repo.list_for_user.call_args.kwargs["extra_ids"] == [shared]
assert repo.count_for_user.call_args.kwargs["extra_ids"] == [shared]
def test_no_shares_returns_only_owned(self, client):
owned = str(uuid.uuid4())
repo = Mock()
repo.count_for_user.return_value = 1
repo.list_for_user.return_value = [_source_row(owned, user_id="bob")]
resp = _run("bob", repo, {}, client)
assert resp.status_code == 200
docs = json.loads(resp.data)["paginated"]
assert len(docs) == 1
assert docs[0]["ownership"] == "user"
assert repo.list_for_user.call_args.kwargs["extra_ids"] == []
File diff suppressed because it is too large Load Diff
+790
View File
@@ -0,0 +1,790 @@
"""Tests for application/api/user/sources/upload.py."""
import io
import json
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.upload.db_session", _yield
), patch(
"application.api.user.sources.upload.db_readonly", _yield
):
yield
def _seed_source(pg_conn, user="u", name="src", **kw):
from application.storage.db.repositories.sources import SourcesRepository
return SourcesRepository(pg_conn).create(name, user_id=user, **kw)
class TestEnforceAudioPathSizeLimit:
def test_noop_for_non_audio(self, tmp_path):
from application.api.user.sources.upload import (
_enforce_audio_path_size_limit,
)
p = tmp_path / "doc.txt"
p.write_bytes(b"x" * 1024)
_enforce_audio_path_size_limit(str(p), "doc.txt")
def test_raises_for_large_audio(self, tmp_path):
from application.api.user.sources.upload import (
_enforce_audio_path_size_limit,
)
from application.stt.upload_limits import AudioFileTooLargeError
p = tmp_path / "audio.mp3"
p.write_bytes(b"x" * 100)
with patch(
"application.api.user.sources.upload.enforce_audio_file_size_limit",
side_effect=AudioFileTooLargeError("too large"),
):
with pytest.raises(AudioFileTooLargeError):
_enforce_audio_path_size_limit(str(p), "audio.mp3")
class TestUploadFile:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.upload import UploadFile
with app.test_request_context("/api/upload", method="POST"):
from flask import request
request.decoded_token = None
response = UploadFile().post()
assert response.status_code == 401
def test_returns_400_missing_fields(self, app):
from application.api.user.sources.upload import UploadFile
with app.test_request_context(
"/api/upload", method="POST",
data={"user": "u"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadFile().post()
assert response.status_code == 400
def test_returns_400_empty_filenames(self, app):
from application.api.user.sources.upload import UploadFile
with app.test_request_context(
"/api/upload", method="POST",
data={
"user": "u", "name": "job",
"file": (io.BytesIO(b""), ""), # empty filename
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadFile().post()
assert response.status_code == 400
def test_uploads_single_file_successfully(self, app):
from application.api.user.sources.upload import UploadFile
fake_storage = MagicMock()
fake_task = MagicMock(id="task-1")
with patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.sources.upload.ingest.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/upload", method="POST",
data={
"user": "alice", "name": "my_job",
"file": (io.BytesIO(b"content"), "doc.txt"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "alice"}
response = UploadFile().post()
assert response.status_code == 200
assert response.json["success"] is True
assert response.json["task_id"] == "task-1"
def test_storage_error_returns_400(self, app):
from application.api.user.sources.upload import UploadFile
fake_storage = MagicMock()
fake_storage.save_file.side_effect = RuntimeError("boom")
with patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), app.test_request_context(
"/api/upload", method="POST",
data={
"user": "alice", "name": "j",
"file": (io.BytesIO(b"content"), "doc.txt"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "alice"}
response = UploadFile().post()
assert response.status_code == 400
def test_uploads_zip_extracts_files(self, app):
from application.api.user.sources.upload import UploadFile
import zipfile
# Build an in-memory zip containing 2 files
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w") as zf:
zf.writestr("a.txt", "content a")
zf.writestr("sub/b.txt", "content b")
zip_buffer.seek(0)
fake_storage = MagicMock()
fake_task = MagicMock(id="task-zip")
with patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.sources.upload.ingest.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/upload", method="POST",
data={
"user": "alice", "name": "job",
"file": (zip_buffer, "docs.zip"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "alice"}
response = UploadFile().post()
assert response.status_code == 200
# save_file called at least twice (for 2 files in zip)
assert fake_storage.save_file.call_count >= 2
def test_office_format_zip_saved_as_is(self, app):
from application.api.user.sources.upload import UploadFile
import zipfile
# .docx is technically a zip but should be saved as-is
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("word/document.xml", "<x/>")
buf.seek(0)
fake_storage = MagicMock()
with patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.sources.upload.ingest.apply_async",
return_value=MagicMock(id="t"),
), app.test_request_context(
"/api/upload", method="POST",
data={
"user": "alice", "name": "job",
"file": (buf, "letter.docx"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "alice"}
response = UploadFile().post()
assert response.status_code == 200
# Saved once (as the .docx directly, not extracted)
assert fake_storage.save_file.call_count == 1
def test_audio_too_large_returns_413(self, app):
from application.api.user.sources.upload import UploadFile
from application.stt.upload_limits import AudioFileTooLargeError
fake_storage = MagicMock()
with patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.sources.upload._enforce_audio_path_size_limit",
side_effect=AudioFileTooLargeError("too large"),
), app.test_request_context(
"/api/upload", method="POST",
data={
"user": "alice", "name": "j",
"file": (io.BytesIO(b"audio-content"), "song.mp3"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "alice"}
response = UploadFile().post()
assert response.status_code == 413
class TestUploadRemote:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.upload import UploadRemote
with app.test_request_context(
"/api/remote", method="POST",
data={"user": "u", "source": "github"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = None
response = UploadRemote().post()
assert response.status_code == 401
def test_returns_missing_fields(self, app):
from application.api.user.sources.upload import UploadRemote
with app.test_request_context(
"/api/remote", method="POST",
data={"user": "u"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadRemote().post()
# check_required_fields returns a response; status is 400
# The response is returned directly by missing_fields branch
assert response.status_code == 400
def test_uploads_github_remote_success(self, app):
from application.api.user.sources.upload import UploadRemote
fake_task = MagicMock(id="remote-task-1")
with patch(
"application.api.user.sources.upload.ingest_remote.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/remote", method="POST",
data={
"user": "u", "source": "github", "name": "gh",
"data": json.dumps({"repo_url": "https://github.com/x/y"}),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadRemote().post()
assert response.status_code == 200
assert response.json["task_id"] == "remote-task-1"
def test_uploads_url_source(self, app):
from application.api.user.sources.upload import UploadRemote
fake_task = MagicMock(id="url-task")
with patch(
"application.api.user.sources.upload.ingest_remote.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/remote", method="POST",
data={
"user": "u", "source": "crawler", "name": "crawl",
"data": json.dumps({"url": "https://example.com"}),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadRemote().post()
assert response.status_code == 200
def test_uploads_reddit_source(self, app):
from application.api.user.sources.upload import UploadRemote
fake_task = MagicMock(id="reddit-task")
with patch(
"application.api.user.sources.upload.ingest_remote.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/remote", method="POST",
data={
"user": "u", "source": "reddit", "name": "r",
"data": json.dumps({"subreddit": "python"}),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadRemote().post()
assert response.status_code == 200
def test_upload_exception_returns_400(self, app):
from application.api.user.sources.upload import UploadRemote
with patch(
"application.api.user.sources.upload.ingest_remote.apply_async",
side_effect=RuntimeError("boom"),
), app.test_request_context(
"/api/remote", method="POST",
data={
"user": "u", "source": "github", "name": "x",
"data": json.dumps({"repo_url": "https://github.com/x/y"}),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UploadRemote().post()
assert response.status_code == 400
class TestManageSourceFiles:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.upload import ManageSourceFiles
with app.test_request_context(
"/api/manage_source_files", method="POST",
data={"source_id": "x", "operation": "add"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = None
response = ManageSourceFiles().post()
assert response.status_code == 401
def test_returns_400_missing_required(self, app):
from application.api.user.sources.upload import ManageSourceFiles
with app.test_request_context(
"/api/manage_source_files", method="POST",
data={"source_id": "x"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_returns_400_invalid_operation(self, app):
from application.api.user.sources.upload import ManageSourceFiles
with app.test_request_context(
"/api/manage_source_files", method="POST",
data={"source_id": "x", "operation": "weird"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
with _patch_db(pg_conn), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": "00000000-0000-0000-0000-000000000000",
"operation": "add",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ManageSourceFiles().post()
assert response.status_code == 404
def test_rejects_bad_parent_dir(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-bad-parent"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "add",
"parent_dir": "/abs-path",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_add_no_files_returns_400(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-add-nofile"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={"source_id": str(src["id"]), "operation": "add"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_add_files_success(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-add-ok"
src = _seed_source(pg_conn, user=user, file_path="/data/src")
fake_storage = MagicMock()
fake_task = MagicMock(id="reingest-1")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.tasks.reingest_source_task.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "add",
"file": (io.BytesIO(b"content"), "new.txt"),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 200
assert response.json["success"] is True
assert "new.txt" in response.json["added_files"]
def test_remove_missing_file_paths_returns_400(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rm-nolist"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={"source_id": str(src["id"]), "operation": "remove"},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_remove_invalid_json_file_paths(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rm-bad"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove",
"file_paths": "not-json",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_remove_rejects_path_traversal(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rm-trav"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove",
"file_paths": json.dumps(["../escape.txt"]),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_remove_files_success(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rm-ok"
src = _seed_source(
pg_conn, user=user,
file_path="/data/src",
file_name_map={"a.txt": "Original A.txt"},
)
fake_storage = MagicMock()
fake_storage.file_exists.return_value = True
fake_task = MagicMock(id="reingest-rm")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.tasks.reingest_source_task.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove",
"file_paths": json.dumps(["a.txt"]),
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 200
assert "a.txt" in response.json["removed_files"]
def test_remove_directory_missing_path(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rmdir-missing"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove_directory",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_remove_directory_rejects_bad_path(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rmdir-bad"
src = _seed_source(pg_conn, user=user, file_path="/data")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=MagicMock(),
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove_directory",
"directory_path": "../escape",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 400
def test_remove_directory_404_when_not_directory(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rmdir-notdir"
src = _seed_source(pg_conn, user=user, file_path="/data")
fake_storage = MagicMock()
fake_storage.is_directory.return_value = False
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove_directory",
"directory_path": "subdir",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 404
def test_remove_directory_success(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rmdir-ok"
src = _seed_source(
pg_conn, user=user, file_path="/data",
file_name_map={"sub/a.txt": "A"},
)
fake_storage = MagicMock()
fake_storage.is_directory.return_value = True
fake_storage.remove_directory.return_value = True
fake_task = MagicMock(id="reingest-dir")
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), patch(
"application.api.user.tasks.reingest_source_task.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove_directory",
"directory_path": "sub",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 200
assert response.json["removed_directory"] == "sub"
def test_remove_directory_storage_failure_returns_500(self, app, pg_conn):
from application.api.user.sources.upload import ManageSourceFiles
user = "u-rmdir-fail"
src = _seed_source(pg_conn, user=user, file_path="/data")
fake_storage = MagicMock()
fake_storage.is_directory.return_value = True
fake_storage.remove_directory.return_value = False
with _patch_db(pg_conn), patch(
"application.api.user.sources.upload.StorageCreator.get_storage",
return_value=fake_storage,
), app.test_request_context(
"/api/manage_source_files", method="POST",
data={
"source_id": str(src["id"]),
"operation": "remove_directory",
"directory_path": "sub",
},
content_type="multipart/form-data",
):
from flask import request
request.decoded_token = {"sub": user}
response = ManageSourceFiles().post()
assert response.status_code == 500
class TestTaskStatus:
def test_returns_400_missing_task_id(self, app):
from application.api.user.sources.upload import TaskStatus
with app.test_request_context("/api/task_status"):
response = TaskStatus().get()
assert response.status_code == 400
def test_returns_task_status(self, app):
from application.api.user.sources.upload import TaskStatus
fake_task = MagicMock()
fake_task.status = "SUCCESS"
fake_task.info = {"result": "ok"}
fake_celery = MagicMock()
fake_celery.AsyncResult.return_value = fake_task
with patch(
"application.celery_init.celery", fake_celery
), app.test_request_context("/api/task_status?task_id=t-123"):
response = TaskStatus().get()
assert response.status_code == 200
assert response.json["status"] == "SUCCESS"
def test_pending_without_workers_returns_503(self, app):
from application.api.user.sources.upload import TaskStatus
fake_task = MagicMock()
fake_task.status = "PENDING"
fake_task.info = None
fake_inspect = MagicMock()
fake_inspect.ping.return_value = None # no workers
fake_celery = MagicMock()
fake_celery.AsyncResult.return_value = fake_task
fake_celery.control.inspect.return_value = fake_inspect
with patch(
"application.celery_init.celery", fake_celery
), app.test_request_context("/api/task_status?task_id=t-999"):
response = TaskStatus().get()
assert response.status_code == 503
def test_exception_returns_400(self, app):
from application.api.user.sources.upload import TaskStatus
fake_celery = MagicMock()
fake_celery.AsyncResult.side_effect = RuntimeError("boom")
with patch(
"application.celery_init.celery", fake_celery
), app.test_request_context("/api/task_status?task_id=t-err"):
response = TaskStatus().get()
assert response.status_code == 400
def test_non_serializable_info_gets_stringified(self, app):
from application.api.user.sources.upload import TaskStatus
class WeirdObj:
def __str__(self):
return "weird-str"
fake_task = MagicMock()
fake_task.status = "SUCCESS"
fake_task.info = WeirdObj()
fake_celery = MagicMock()
fake_celery.AsyncResult.return_value = fake_task
with patch(
"application.celery_init.celery", fake_celery
), app.test_request_context("/api/task_status?task_id=t-weird"):
response = TaskStatus().get()
assert response.status_code == 200
assert response.json["result"] == "weird-str"
File diff suppressed because it is too large Load Diff
+602
View File
@@ -0,0 +1,602 @@
"""Tests for the wiki source routes in application/api/user/sources/routes.py."""
import uuid
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from flask import Flask
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.routes.db_session", _yield
), patch(
"application.api.user.sources.routes.db_readonly", _yield
):
yield
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], member, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
access_level=access_level,
)
class TestCreateWikiSource:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import CreateWikiSource
with app.test_request_context(
"/api/sources/wiki", method="POST", json={"name": "w"}
):
from flask import request
request.decoded_token = None
response = CreateWikiSource().post()
assert response.status_code == 401
def test_returns_400_missing_name(self, app, pg_conn):
from application.api.user.sources.routes import CreateWikiSource
with _patch_db(pg_conn), app.test_request_context(
"/api/sources/wiki", method="POST", json={}
):
from flask import request
request.decoded_token = {"sub": "u-wiki"}
response = CreateWikiSource().post()
assert response.status_code == 400
def test_creates_row_without_ingest(self, app, pg_conn):
from application.api.user.sources.routes import CreateWikiSource
from application.storage.db.repositories.sources import SourcesRepository
user = "u-wiki-create"
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, patch(
"application.api.user.tasks.ingest.delay"
) as mock_ingest, patch(
"application.api.user.tasks.reingest_source_task.delay"
) as mock_reingest, app.test_request_context(
"/api/sources/wiki", method="POST", json={"name": "My Wiki"}
):
from flask import request
request.decoded_token = {"sub": user}
response = CreateWikiSource().post()
assert response.status_code == 200
source_id = response.json["source_id"]
row = SourcesRepository(pg_conn).get_any(source_id, user)
assert row["type"] == "wiki"
assert row["config"]["kind"] == "wiki"
assert int(row["tokens"]) == 0
# No seed content → no re-embed, and never any ingest/reingest task.
mock_reembed.assert_not_called()
mock_ingest.assert_not_called()
mock_reingest.assert_not_called()
def test_seed_page_roundtrips_and_only_seed_reembeds(self, app, pg_conn):
from application.api.user.sources.routes import (
CreateWikiSource,
WikiPage,
WIKI_INDEX_PATH,
)
from application.storage.db.repositories.sources import SourcesRepository
user = "u-wiki-seed"
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, patch(
"application.api.user.tasks.ingest.delay"
) as mock_ingest, patch(
"application.api.user.tasks.reingest_source_task.delay"
) as mock_reingest, app.test_request_context(
"/api/sources/wiki",
method="POST",
json={"name": "Seeded", "initial_content": "# Hello\nworld"},
):
from flask import request
request.decoded_token = {"sub": user}
response = CreateWikiSource().post()
assert response.status_code == 200
source_id = response.json["source_id"]
row = SourcesRepository(pg_conn).get_any(source_id, user)
assert int(row["tokens"]) > 0
# Re-embed fires once, only for the seed page; no ingest/reingest ever.
mock_reembed.assert_called_once()
assert mock_reembed.call_args.args[0] == source_id
assert mock_reembed.call_args.args[1] == WIKI_INDEX_PATH
assert mock_reembed.call_args.kwargs["user"] == user
mock_ingest.assert_not_called()
mock_reingest.assert_not_called()
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{source_id}/wiki/page?path={WIKI_INDEX_PATH}"
):
from flask import request
request.decoded_token = {"sub": user}
page_response = WikiPage().get(source_id)
assert page_response.status_code == 200
assert page_response.json["page"]["content"] == "# Hello\nworld"
class TestWikiPages:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import WikiPages
with app.test_request_context("/api/sources/x/wiki/pages"):
from flask import request
request.decoded_token = None
response = WikiPages().get("x")
assert response.status_code == 401
def test_owner_lists_pages(self, app, pg_conn):
from application.api.user.sources.routes import WikiPages
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
user = "u-wiki-list"
src = SourcesRepository(pg_conn).create(
"wiki-list", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
WikiPagesRepository(pg_conn).upsert(
sid, "/index.md", "root", updated_by=user, updated_via="agent"
)
WikiPagesRepository(pg_conn).upsert(sid, "/docs/a.md", "a", updated_by=user)
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/pages"
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPages().get(sid)
assert response.status_code == 200
paths = {p["path"] for p in response.json["pages"]}
assert paths == {"/index.md", "/docs/a.md"}
via = {p["path"]: p["updated_via"] for p in response.json["pages"]}
assert via["/index.md"] == "agent"
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import WikiPages
from application.storage.db.repositories.sources import SourcesRepository
owner = "u-wiki-owner"
stranger = "u-wiki-stranger"
src = SourcesRepository(pg_conn).create(
"wiki-private", user_id=owner, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/pages"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = WikiPages().get(sid)
assert response.status_code == 404
def test_team_viewer_can_read(self, app, pg_conn):
from application.api.user.sources.routes import WikiPages
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
owner = "alice-wiki"
viewer = "bob-wiki-viewer"
src = SourcesRepository(pg_conn).create(
"wiki-shared", user_id=owner, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "x", updated_by=owner)
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/pages"
):
from flask import request
request.decoded_token = {"sub": viewer}
response = WikiPages().get(sid)
assert response.status_code == 200
assert {p["path"] for p in response.json["pages"]} == {"/index.md"}
class TestWikiPage:
def test_returns_400_missing_path(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
user = "u-wiki-page-nopath"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/page"
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().get(sid)
assert response.status_code == 400
def test_returns_400_traversal_path(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
user = "u-wiki-page-traversal"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/page?path=/../secret.md"
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().get(sid)
assert response.status_code == 400
def test_returns_404_unknown_page(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
user = "u-wiki-page-missing"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/page?path=/nope.md"
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().get(sid)
assert response.status_code == 404
def test_returns_provenance_and_version(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
user = "u-wiki-page-provenance"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
WikiPagesRepository(pg_conn).upsert(
sid, "/index.md", "x", updated_by=user, updated_via="agent"
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/page?path=/index.md"
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().get(sid)
assert response.status_code == 200
page = response.json["page"]
assert page["updated_via"] == "agent"
assert page["updated_by"] == user
assert page["version"] == 1
assert page["updated_at"] is not None
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
owner = "u-wiki-pg-owner"
stranger = "u-wiki-pg-stranger"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "secret", updated_by=owner)
with _patch_db(pg_conn), app.test_request_context(
f"/api/sources/{sid}/wiki/page?path=/index.md"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = WikiPage().get(sid)
assert response.status_code == 404
class TestConvertSourceToWiki:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import ConvertSourceToWiki
with app.test_request_context(
"/api/sources/x/wiki/convert", method="POST"
):
from flask import request
request.decoded_token = None
response = ConvertSourceToWiki().post("x")
assert response.status_code == 401
def test_blank_source_enabled_inline_no_task(self, app, pg_conn):
from application.api.user.sources.routes import ConvertSourceToWiki
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.source_config import SourceConfig
user = "u-convert-blank"
src = SourcesRepository(pg_conn).create(
"blank", user_id=user, type="file", directory_structure={}
)
sid = str(src["id"])
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.convert_source_to_wiki.delay"
) as mock_convert, app.test_request_context(
f"/api/sources/{sid}/wiki/convert", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
response = ConvertSourceToWiki().post(sid)
assert response.status_code == 200
assert response.json["converted"] is False
assert response.json["enabled"] is True
mock_convert.assert_not_called()
got = SourcesRepository(pg_conn).get_any(sid, user)
cfg = SourceConfig.parse(got.get("config"))
assert cfg.kind == "wiki"
assert cfg.retrieval.exposure == "agentic_tool"
def test_fileful_source_enqueues_task(self, app, pg_conn):
from application.api.user.sources.routes import ConvertSourceToWiki
from application.storage.db.repositories.sources import SourcesRepository
user = "u-convert-files"
src = SourcesRepository(pg_conn).create(
"files", user_id=user, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
fake_task = type("T", (), {"id": "task-xyz"})()
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.convert_source_to_wiki.delay",
return_value=fake_task,
) as mock_convert, app.test_request_context(
f"/api/sources/{sid}/wiki/convert", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
response = ConvertSourceToWiki().post(sid)
assert response.status_code == 200
assert response.json["task_id"] == "task-xyz"
mock_convert.assert_called_once()
assert mock_convert.call_args.kwargs["source_id"] == sid
assert mock_convert.call_args.kwargs["user"] == user
assert mock_convert.call_args.kwargs["idempotency_key"] == (
f"convert-wiki:{sid}"
)
def test_in_progress_ingest_rejected_409(self, app, pg_conn):
from application.api.user.sources.routes import ConvertSourceToWiki
from application.storage.db.repositories.ingest_chunk_progress import (
IngestChunkProgressRepository,
)
from application.storage.db.repositories.sources import SourcesRepository
user = "u-convert-ingesting"
src = SourcesRepository(pg_conn).create(
"ingesting", user_id=user, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
# An active embed in flight (embedded 0 of 5) → "processing".
IngestChunkProgressRepository(pg_conn).init_progress(sid, 5)
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.convert_source_to_wiki.delay"
) as mock_convert, app.test_request_context(
f"/api/sources/{sid}/wiki/convert", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
response = ConvertSourceToWiki().post(sid)
assert response.status_code == 409
mock_convert.assert_not_called()
def test_viewer_rejected_403(self, app, pg_conn):
from application.api.user.sources.routes import ConvertSourceToWiki
from application.storage.db.repositories.sources import SourcesRepository
owner = "alice-convert"
viewer = "bob-convert-viewer"
src = SourcesRepository(pg_conn).create(
"files", user_id=owner, type="file",
directory_structure={"a.md": {"type": "text/markdown"}},
)
sid = str(src["id"])
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.convert_source_to_wiki.delay"
) as mock_convert, app.test_request_context(
f"/api/sources/{sid}/wiki/convert", method="POST"
):
from flask import request
request.decoded_token = {"sub": viewer}
response = ConvertSourceToWiki().post(sid)
assert response.status_code == 403
mock_convert.assert_not_called()
class TestWikiPageEdit:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import WikiPage
with app.test_request_context(
"/api/sources/x/wiki/page", method="PUT", json={}
):
from flask import request
request.decoded_token = None
response = WikiPage().put("x")
assert response.status_code == 401
def test_owner_writes_and_enqueues_reembed(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
user = "u-edit-owner"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, app.test_request_context(
f"/api/sources/{sid}/wiki/page",
method="PUT",
json={"path": "/notes.md", "content": "body"},
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().put(sid)
assert response.status_code == 200
assert response.json["page"]["path"] == "/notes.md"
assert response.json["page"]["version"] is not None
assert response.json["page"]["updated_via"] == "human"
page = WikiPagesRepository(pg_conn).get_by_path(sid, "/notes.md")
assert page["content"] == "body"
assert page["updated_via"] == "human"
mock_reembed.assert_called_once()
assert mock_reembed.call_args.args[1] == "/notes.md"
assert mock_reembed.call_args.kwargs["user"] == user
def test_team_editor_reembeds_as_owner(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
owner = "alice-edit"
editor = "bob-edit-editor"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
_grant_team_access(pg_conn, owner, editor, sid, "editor")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, app.test_request_context(
f"/api/sources/{sid}/wiki/page",
method="PUT",
json={"path": "/e.md", "content": "edited"},
):
from flask import request
request.decoded_token = {"sub": editor}
response = WikiPage().put(sid)
assert response.status_code == 200
# Re-embed runs AS the owner so the owner-scoped worker load resolves.
assert mock_reembed.call_args.kwargs["user"] == owner
def test_stale_version_returns_409(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
user = "u-edit-conflict"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v1", updated_by=user)
# Bump to version 2 so a stale expected_version=1 loses the race.
WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v2", updated_by=user)
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, app.test_request_context(
f"/api/sources/{sid}/wiki/page",
method="PUT",
json={"path": "/c.md", "content": "v3", "expected_version": 1},
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().put(sid)
assert response.status_code == 409
mock_reembed.assert_not_called()
def test_traversal_path_returns_400(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
user = "u-edit-traversal"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
), app.test_request_context(
f"/api/sources/{sid}/wiki/page",
method="PUT",
json={"path": "/../secret.md", "content": "x"},
):
from flask import request
request.decoded_token = {"sub": user}
response = WikiPage().put(sid)
assert response.status_code == 400
def test_viewer_rejected_403(self, app, pg_conn):
from application.api.user.sources.routes import WikiPage
from application.storage.db.repositories.sources import SourcesRepository
owner = "alice-edit-viewer"
viewer = "bob-edit-viewer"
src = SourcesRepository(pg_conn).create(
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
)
sid = str(src["id"])
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reembed_wiki_page.delay"
) as mock_reembed, app.test_request_context(
f"/api/sources/{sid}/wiki/page",
method="PUT",
json={"path": "/v.md", "content": "x"},
):
from flask import request
request.decoded_token = {"sub": viewer}
response = WikiPage().put(sid)
assert response.status_code == 403
mock_reembed.assert_not_called()