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
View File
+524
View File
@@ -0,0 +1,524 @@
"""Tests for application/api/user/agents/folders.py.
Uses the ephemeral ``pg_conn`` fixture to exercise real PG repository code.
"""
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_folders_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.agents.folders.db_session", _yield
), patch(
"application.api.user.agents.folders.db_readonly", _yield
):
yield
def _seed_folder(pg_conn, user, name="F", parent_id=None):
from application.storage.db.repositories.agent_folders import (
AgentFoldersRepository,
)
return AgentFoldersRepository(pg_conn).create(user, name, parent_id=parent_id)
def _seed_agent(pg_conn, user, folder_id=None):
from application.storage.db.repositories.agents import AgentsRepository
repo = AgentsRepository(pg_conn)
agent = repo.create(user, "test-agent", "published", description="x")
if folder_id:
repo.set_folder(str(agent["id"]), user, folder_id)
return agent
class TestAgentFoldersGet:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolders
with app.test_request_context("/api/agents/folders/"):
from flask import request
request.decoded_token = None
response = AgentFolders().get()
assert response.status_code == 401
def test_returns_folders_list(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolders
user = "u-folders-list"
_seed_folder(pg_conn, user, name="A")
_seed_folder(pg_conn, user, name="B")
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/"
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolders().get()
assert response.status_code == 200
names = [f["name"] for f in response.json["folders"]]
assert "A" in names and "B" in names
class TestAgentFoldersPost:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolders
with app.test_request_context(
"/api/agents/folders/", method="POST", json={"name": "F"}
):
from flask import request
request.decoded_token = None
response = AgentFolders().post()
assert response.status_code == 401
def test_returns_400_missing_name(self, app):
from application.api.user.agents.folders import AgentFolders
with app.test_request_context(
"/api/agents/folders/", method="POST", json={}
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentFolders().post()
assert response.status_code == 400
def test_creates_folder_at_root(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolders
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/", method="POST",
json={"name": "Root Folder"},
):
from flask import request
request.decoded_token = {"sub": "u-create"}
response = AgentFolders().post()
assert response.status_code == 201
assert response.json["name"] == "Root Folder"
assert response.json["parent_id"] is None
def test_creates_nested_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolders
user = "u-nested"
parent = _seed_folder(pg_conn, user, name="parent")
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/", method="POST",
json={"name": "child", "parent_id": str(parent["id"])},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolders().post()
assert response.status_code == 201
assert response.json["parent_id"] == str(parent["id"])
def test_returns_404_for_missing_parent(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolders
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/", method="POST",
json={
"name": "child",
"parent_id": "00000000-0000-0000-0000-000000000000",
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentFolders().post()
assert response.status_code == 404
class TestAgentFolderGet:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolder
with app.test_request_context("/api/agents/folders/abc"):
from flask import request
request.decoded_token = None
response = AgentFolder().get("abc")
assert response.status_code == 401
def test_returns_404_missing_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/00000000-0000-0000-0000-000000000000"
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentFolder().get(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_returns_folder_with_agents_and_subfolders(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-folder-detail"
parent = _seed_folder(pg_conn, user, name="parent")
_seed_folder(pg_conn, user, name="sub", parent_id=str(parent["id"]))
_seed_agent(pg_conn, user, folder_id=str(parent["id"]))
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{parent['id']}"
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().get(str(parent["id"]))
assert response.status_code == 200
data = response.json
assert data["name"] == "parent"
assert len(data["agents"]) == 1
assert len(data["subfolders"]) == 1
class TestAgentFolderPut:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolder
with app.test_request_context(
"/api/agents/folders/abc", method="PUT", json={"name": "new"}
):
from flask import request
request.decoded_token = None
response = AgentFolder().put("abc")
assert response.status_code == 401
def test_returns_404_missing_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/00000000-0000-0000-0000-000000000000",
method="PUT",
json={"name": "new"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentFolder().put(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_renames_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-rename"
folder = _seed_folder(pg_conn, user, name="old")
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{folder['id']}",
method="PUT",
json={"name": "renamed"},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().put(str(folder["id"]))
assert response.status_code == 200
def test_prevents_setting_self_as_parent(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-self-parent"
folder = _seed_folder(pg_conn, user, name="f1")
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{folder['id']}",
method="PUT",
json={"parent_id": str(folder["id"])},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().put(str(folder["id"]))
assert response.status_code == 400
def test_sets_parent_to_other_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-moveparent"
f1 = _seed_folder(pg_conn, user, name="f1")
f2 = _seed_folder(pg_conn, user, name="f2")
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{f1['id']}",
method="PUT",
json={"parent_id": str(f2["id"])},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().put(str(f1["id"]))
assert response.status_code == 200
def test_returns_404_for_missing_parent(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-missingparent"
folder = _seed_folder(pg_conn, user, name="f")
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{folder['id']}",
method="PUT",
json={"parent_id": "00000000-0000-0000-0000-000000000000"},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().put(str(folder["id"]))
assert response.status_code == 404
def test_clears_parent_id_when_null(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-clear"
parent = _seed_folder(pg_conn, user, name="p")
child = _seed_folder(
pg_conn, user, name="c", parent_id=str(parent["id"])
)
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{child['id']}",
method="PUT",
json={"parent_id": None},
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().put(str(child["id"]))
assert response.status_code == 200
class TestAgentFolderDelete:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolder
with app.test_request_context(
"/api/agents/folders/abc", method="DELETE"
):
from flask import request
request.decoded_token = None
response = AgentFolder().delete("abc")
assert response.status_code == 401
def test_returns_404_missing_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/00000000-0000-0000-0000-000000000000",
method="DELETE",
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentFolder().delete(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_deletes_folder(self, app, pg_conn):
from application.api.user.agents.folders import AgentFolder
user = "u-del"
folder = _seed_folder(pg_conn, user, name="tbd")
with _patch_folders_db(pg_conn), app.test_request_context(
f"/api/agents/folders/{folder['id']}", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentFolder().delete(str(folder["id"]))
assert response.status_code == 200
class TestMoveAgentToFolder:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={"agent_id": "x"},
):
from flask import request
request.decoded_token = None
response = MoveAgentToFolder().post()
assert response.status_code == 401
def test_returns_400_missing_agent_id(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MoveAgentToFolder().post()
assert response.status_code == 400
def test_returns_404_agent_not_found(self, app, pg_conn):
from application.api.user.agents.folders import MoveAgentToFolder
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={"agent_id": "00000000-0000-0000-0000-000000000000"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MoveAgentToFolder().post()
assert response.status_code == 404
def test_moves_agent_into_folder(self, app, pg_conn):
from application.api.user.agents.folders import MoveAgentToFolder
user = "u-move"
folder = _seed_folder(pg_conn, user, name="target")
agent = _seed_agent(pg_conn, user)
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={
"agent_id": str(agent["id"]),
"folder_id": str(folder["id"]),
},
):
from flask import request
request.decoded_token = {"sub": user}
response = MoveAgentToFolder().post()
assert response.status_code == 200
def test_returns_404_folder_not_found(self, app, pg_conn):
from application.api.user.agents.folders import MoveAgentToFolder
user = "u-move-nofolder"
agent = _seed_agent(pg_conn, user)
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={
"agent_id": str(agent["id"]),
"folder_id": "00000000-0000-0000-0000-000000000000",
},
):
from flask import request
request.decoded_token = {"sub": user}
response = MoveAgentToFolder().post()
assert response.status_code == 404
def test_removes_agent_from_folder(self, app, pg_conn):
from application.api.user.agents.folders import MoveAgentToFolder
user = "u-remove"
folder = _seed_folder(pg_conn, user, name="target")
agent = _seed_agent(pg_conn, user, folder_id=str(folder["id"]))
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={"agent_id": str(agent["id"]), "folder_id": None},
):
from flask import request
request.decoded_token = {"sub": user}
response = MoveAgentToFolder().post()
assert response.status_code == 200
class TestBulkMoveAgents:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import BulkMoveAgents
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={"agent_ids": ["a"]},
):
from flask import request
request.decoded_token = None
response = BulkMoveAgents().post()
assert response.status_code == 401
def test_returns_400_missing_agent_ids(self, app):
from application.api.user.agents.folders import BulkMoveAgents
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = BulkMoveAgents().post()
assert response.status_code == 400
def test_bulk_moves_agents(self, app, pg_conn):
from application.api.user.agents.folders import BulkMoveAgents
user = "u-bulk"
folder = _seed_folder(pg_conn, user, name="dest")
a1 = _seed_agent(pg_conn, user)
a2 = _seed_agent(pg_conn, user)
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={
"agent_ids": [str(a1["id"]), str(a2["id"])],
"folder_id": str(folder["id"]),
},
):
from flask import request
request.decoded_token = {"sub": user}
response = BulkMoveAgents().post()
assert response.status_code == 200
def test_returns_404_when_folder_not_found(self, app, pg_conn):
from application.api.user.agents.folders import BulkMoveAgents
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={
"agent_ids": ["a"],
"folder_id": "00000000-0000-0000-0000-000000000000",
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = BulkMoveAgents().post()
assert response.status_code == 404
def test_bulk_move_tolerates_missing_agents(self, app, pg_conn):
from application.api.user.agents.folders import BulkMoveAgents
user = "u-bulk-partial"
folder = _seed_folder(pg_conn, user, name="f")
a1 = _seed_agent(pg_conn, user)
with _patch_folders_db(pg_conn), app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={
"agent_ids": [
str(a1["id"]),
"00000000-0000-0000-0000-000000000000",
],
"folder_id": str(folder["id"]),
},
):
from flask import request
request.decoded_token = {"sub": user}
response = BulkMoveAgents().post()
assert response.status_code == 200
+120
View File
@@ -0,0 +1,120 @@
"""Gap-coverage tests for application.api.user.agents.routes.
No bson/ObjectId imports. The routes internally use ObjectId (patched where
needed) and Mongo collections (mocked). Repository assertions use
AgentsRepository patches for Postgres-specific paths.
"""
import uuid
from unittest.mock import Mock
import pytest
from flask import Flask
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def app():
return Flask(__name__)
def _fake_oid():
"""Return a 24-character hex string that looks like a Mongo ObjectId."""
return uuid.uuid4().hex[:24]
# ---------------------------------------------------------------------------
# normalize_workflow_reference — pure utility, no bson needed
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestNormalizeWorkflowReferenceGaps:
pass
def test_handles_integer_zero(self):
from application.api.user.agents.routes import normalize_workflow_reference
assert normalize_workflow_reference(0) == "0"
def test_handles_list_converts_to_str(self):
from application.api.user.agents.routes import normalize_workflow_reference
result = normalize_workflow_reference([1, 2])
assert isinstance(result, str)
def test_json_string_with_underscore_id(self):
from application.api.user.agents.routes import normalize_workflow_reference
result = normalize_workflow_reference('{"_id": "abc"}')
assert result == "abc"
def test_json_string_with_workflow_id_key(self):
from application.api.user.agents.routes import normalize_workflow_reference
result = normalize_workflow_reference('{"workflow_id": "wf99"}')
assert result == "wf99"
def test_whitespace_only_string_returns_empty(self):
from application.api.user.agents.routes import normalize_workflow_reference
assert normalize_workflow_reference(" \t ") == ""
def test_dict_missing_all_id_keys_returns_none(self):
from application.api.user.agents.routes import normalize_workflow_reference
result = normalize_workflow_reference({"other_key": "value"})
assert result is None
# ---------------------------------------------------------------------------
# validate_workflow_access — mocked ObjectId + workflows_collection
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateWorkflowAccessGaps:
pass
def _fake_objectid(self, valid: bool = True):
"""Return a Mock that mimics bson.ObjectId."""
oid_cls = Mock()
oid_cls.is_valid = Mock(return_value=valid)
oid_cls.return_value = Mock()
return oid_cls
# ---------------------------------------------------------------------------
# build_agent_document — pure logic, no DB needed
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildAgentDocumentGaps:
pass
# ---------------------------------------------------------------------------
# GetAgent route — additional edge cases (uuid-based IDs, no bson)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetAgentGaps:
pass
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,260 @@
"""Tests for helper functions in application/api/user/agents/routes.py."""
import json
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.agents.routes.db_session", _yield
), patch(
"application.api.user.agents.routes.db_readonly", _yield
):
yield
class TestNormalizeWorkflowReference:
def test_none_returns_none(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference(None) is None
def test_dict_returns_id_field(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference({"id": "w1"}) == "w1"
def test_dict_returns_workflow_id_field(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert (
normalize_workflow_reference({"workflow_id": "wf99"}) == "wf99"
)
def test_dict_with_underscore_id(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference({"_id": "wf-2"}) == "wf-2"
def test_empty_string_returns_empty(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference("") == ""
def test_plain_string_returned(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference("wf-123") == "wf-123"
def test_json_string_dict(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert (
normalize_workflow_reference(json.dumps({"id": "x"})) == "x"
)
def test_json_string_as_string(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference('"wf-str"') == "wf-str"
def test_invalid_json_returns_original(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference("not-json-wf") == "not-json-wf"
def test_non_string_non_dict_coerced(self):
from application.api.user.agents.routes import (
normalize_workflow_reference,
)
assert normalize_workflow_reference(42) == "42"
class TestResolveWorkflowForUser:
def test_none_workflow_returns_none(self, pg_conn):
from application.api.user.agents.routes import (
_resolve_workflow_for_user,
)
pg_id, err = _resolve_workflow_for_user(pg_conn, None, "u")
assert pg_id is None and err is None
def test_not_found_returns_error(self, pg_conn, app):
from application.api.user.agents.routes import (
_resolve_workflow_for_user,
)
with app.app_context():
pg_id, err = _resolve_workflow_for_user(
pg_conn,
"00000000-0000-0000-0000-000000000000",
"u",
)
assert pg_id is None
assert err is not None
assert err.status_code == 404
def test_resolves_owned_workflow(self, pg_conn, app):
from application.api.user.agents.routes import (
_resolve_workflow_for_user,
)
from application.storage.db.repositories.workflows import (
WorkflowsRepository,
)
user = "owner"
wf = WorkflowsRepository(pg_conn).create(user, "wf")
with app.app_context():
pg_id, err = _resolve_workflow_for_user(
pg_conn, str(wf["id"]), user,
)
assert pg_id == str(wf["id"])
assert err is None
class TestResolveFolderId:
def test_none_returns_none(self, pg_conn):
from application.api.user.agents.routes import _resolve_folder_id
pg_id, err = _resolve_folder_id(pg_conn, None, "u")
assert pg_id is None
assert err is None
def test_not_found_returns_error(self, pg_conn, app):
from application.api.user.agents.routes import _resolve_folder_id
with app.app_context():
pg_id, err = _resolve_folder_id(
pg_conn, "00000000-0000-0000-0000-000000000000", "u",
)
assert pg_id is None
assert err.status_code == 404
def test_resolves_owned_folder(self, pg_conn, app):
from application.api.user.agents.routes import _resolve_folder_id
from application.storage.db.repositories.agent_folders import (
AgentFoldersRepository,
)
user = "u"
folder = AgentFoldersRepository(pg_conn).create(user, "F")
with app.app_context():
pg_id, err = _resolve_folder_id(pg_conn, str(folder["id"]), user)
assert pg_id == str(folder["id"])
assert err is None
class TestFormatAgentOutput:
def test_basic_shape(self):
from application.api.user.agents.routes import _format_agent_output
agent = {
"id": "agent-1",
"name": "My",
"description": "D",
"source_id": "src-1",
"extra_source_ids": ["s1", "s2"],
"chunks": 4,
"retriever": "classic",
"prompt_id": "p-1",
"tools": [],
"agent_type": "classic",
"status": "draft",
"key": "secret-api-key-long",
}
out = _format_agent_output(agent)
assert out["id"] == "agent-1"
assert out["name"] == "My"
assert out["source"] == "src-1"
assert out["sources"] == ["s1", "s2"]
# masked key: first4...last4
assert out["key"].startswith("secr") and out["key"].endswith("long")
def test_no_key_masking(self):
from application.api.user.agents.routes import _format_agent_output
agent = {"id": "a", "name": "n", "chunks": None}
out = _format_agent_output(agent, include_key_masked=False)
assert "key" not in out
def test_empty_key_returns_empty_string(self):
from application.api.user.agents.routes import _format_agent_output
agent = {"id": "a", "name": "n", "key": ""}
out = _format_agent_output(agent)
assert out["key"] == ""
def test_with_folder_and_workflow(self):
from application.api.user.agents.routes import _format_agent_output
agent = {
"id": "a", "name": "n",
"folder_id": "f-1", "workflow_id": "w-1",
}
out = _format_agent_output(agent)
assert out["folder_id"] == "f-1"
assert out["workflow"] == "w-1"
class TestBuildCreateKwargs:
def test_classic_kwargs(self):
from application.api.user.agents.routes import _build_create_kwargs
data = {
"description": "d",
"agent_type": "classic",
"chunks": "3",
"retriever": "classic",
}
out = _build_create_kwargs(
data, image_url="", agent_type="classic",
)
assert out["description"] == "d"
assert out["chunks"] == 3
def test_invalid_chunks_skipped(self, app):
from application.api.user.agents.routes import _build_create_kwargs
data = {"chunks": "abc"}
with app.app_context():
out = _build_create_kwargs(
data, image_url="", agent_type="classic",
)
assert "chunks" not in out
def test_prompt_id_default_not_set(self):
from application.api.user.agents.routes import _build_create_kwargs
data = {"prompt_id": "default"}
out = _build_create_kwargs(
data, image_url="", agent_type="classic",
)
assert "prompt_id" not in out
def test_image_url_used_when_provided(self):
from application.api.user.agents.routes import _build_create_kwargs
out = _build_create_kwargs(
{}, image_url="/upload/img.png", agent_type="classic",
)
assert out.get("image") == "/upload/img.png"
+290
View File
@@ -0,0 +1,290 @@
"""Tests for application/api/user/agents/sharing.py.
Uses the ephemeral ``pg_conn`` fixture to exercise the real PG repository
code paths (agents, users).
"""
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.agents.sharing.db_session", _yield
), patch(
"application.api.user.agents.sharing.db_readonly", _yield
):
yield
def _make_agent(pg_conn, user_id="owner", *, shared=False, shared_token=None):
from application.storage.db.repositories.agents import AgentsRepository
agent = AgentsRepository(pg_conn).create(
user_id,
"Agent",
"published",
description="desc",
)
if shared:
AgentsRepository(pg_conn).update(
str(agent["id"]), user_id,
{"shared": True, "shared_token": shared_token},
)
agent = AgentsRepository(pg_conn).get(str(agent["id"]), user_id)
return agent
class TestSharedAgentGet:
def test_returns_400_missing_token(self, app):
from application.api.user.agents.sharing import SharedAgent
with app.test_request_context("/api/shared_agent"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 400
def test_returns_404_for_unknown_token(self, app, pg_conn):
from application.api.user.agents.sharing import SharedAgent
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agent?token=unknown"
):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 404
def test_returns_agent_for_known_token(self, app, pg_conn):
from application.api.user.agents.sharing import SharedAgent
_make_agent(pg_conn, shared=True, shared_token="abc123")
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agent?token=abc123"
):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
data = response.json
assert data["shared_token"] == "abc123"
assert data["shared"] is True
def test_records_shared_with_different_user(self, app, pg_conn):
from application.api.user.agents.sharing import SharedAgent
_make_agent(pg_conn, user_id="owner", shared=True, shared_token="tk1")
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agent?token=tk1"
):
from flask import request
request.decoded_token = {"sub": "other-user"}
response = SharedAgent().get()
assert response.status_code == 200
def test_db_error_returns_400(self, app):
from application.api.user.agents.sharing import SharedAgent
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.agents.sharing.db_readonly", _broken
), app.test_request_context("/api/shared_agent?token=x"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 400
class TestSharedAgents:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.sharing import SharedAgents
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = None
response = SharedAgents().get()
assert response.status_code == 401
def test_returns_empty_list_for_new_user(self, app, pg_conn):
from application.api.user.agents.sharing import SharedAgents
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agents"
):
from flask import request
request.decoded_token = {"sub": "new-user"}
response = SharedAgents().get()
assert response.status_code == 200
assert response.json == []
def test_returns_shared_agents_for_user(self, app, pg_conn):
"""After SharedAgent adds an agent to the user's shared_with_me, it
should appear in SharedAgents."""
from application.api.user.agents.sharing import SharedAgent, SharedAgents
_make_agent(pg_conn, user_id="owner", shared=True, shared_token="tk2")
# Trigger add-shared flow via SharedAgent
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agent?token=tk2"
):
from flask import request
request.decoded_token = {"sub": "viewer-user"}
SharedAgent().get()
with _patch_db(pg_conn), app.test_request_context(
"/api/shared_agents"
):
from flask import request
request.decoded_token = {"sub": "viewer-user"}
response = SharedAgents().get()
assert response.status_code == 200
assert len(response.json) == 1
assert response.json[0]["shared"] is True
def test_db_error_returns_400(self, app):
from application.api.user.agents.sharing import SharedAgents
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.agents.sharing.db_session", _broken
), app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "u"}
response = SharedAgents().get()
assert response.status_code == 400
class TestShareAgent:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent", method="PUT", json={"id": "x", "shared": True}
):
from flask import request
request.decoded_token = None
response = ShareAgent().put()
assert response.status_code == 401
def test_returns_400_missing_id(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent", method="PUT", json={"shared": True}
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareAgent().put()
assert response.status_code == 400
def test_returns_400_missing_shared(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent", method="PUT", json={"id": "x"}
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareAgent().put()
assert response.status_code == 400
def test_returns_404_agent_not_found(self, app, pg_conn):
from application.api.user.agents.sharing import ShareAgent
with _patch_db(pg_conn), app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": "00000000-0000-0000-0000-000000000000", "shared": True},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareAgent().put()
assert response.status_code == 404
def test_shares_agent(self, app, pg_conn):
from application.api.user.agents.sharing import ShareAgent
from application.storage.db.repositories.agents import AgentsRepository
agent = _make_agent(pg_conn, user_id="owner")
agent_id = str(agent["id"])
with _patch_db(pg_conn), app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": agent_id, "shared": True, "username": "alice"},
):
from flask import request
request.decoded_token = {"sub": "owner"}
response = ShareAgent().put()
assert response.status_code == 200
assert response.json["success"] is True
assert response.json["shared_token"] # non-empty token generated
got = AgentsRepository(pg_conn).get(agent_id, "owner")
assert got["shared"] is True
assert got["shared_token"]
assert got["shared_metadata"]["shared_by"] == "alice"
def test_unshares_agent(self, app, pg_conn):
from application.api.user.agents.sharing import ShareAgent
from application.storage.db.repositories.agents import AgentsRepository
agent = _make_agent(pg_conn, user_id="owner", shared=True, shared_token="tk")
agent_id = str(agent["id"])
with _patch_db(pg_conn), app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": agent_id, "shared": False},
):
from flask import request
request.decoded_token = {"sub": "owner"}
response = ShareAgent().put()
assert response.status_code == 200
got = AgentsRepository(pg_conn).get(agent_id, "owner")
assert got["shared"] is False
assert got["shared_token"] is None
def test_db_error_returns_400(self, app):
from application.api.user.agents.sharing import ShareAgent
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.agents.sharing.db_session", _broken
), app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": "x", "shared": True},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareAgent().put()
assert response.status_code == 400
+297
View File
@@ -0,0 +1,297 @@
"""Gap-coverage tests for application.api.user.agents.webhooks.
These tests use only stdlib IDs (uuid / hex strings) — no bson/ObjectId.
The agent routes still read from Mongo collections internally; we mock
those collection objects so the tests run without pymongo installed.
"""
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
from flask import Flask
def _fake_oid():
"""24-character hex string used as a substitute for a Mongo ObjectId string."""
return uuid.uuid4().hex[:24]
@pytest.fixture
def app():
return Flask(__name__)
# ---------------------------------------------------------------------------
# AgentWebhook.get — additional coverage beyond test_webhooks.py
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAgentWebhookGetGaps:
pass
# ---------------------------------------------------------------------------
# AgentWebhookListener — additional POST / GET edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAgentWebhookListenerGaps:
pass
def test_post_empty_payload_still_enqueues(self, app):
"""Empty dict payload does not block task enqueue (warning only)."""
from application.api.user.agents.webhooks import AgentWebhookListener
mock_task = Mock()
mock_task.id = "task_empty"
with patch(
"application.api.user.agents.webhooks.process_agent_webhook"
) as mock_process:
mock_process.apply_async.return_value = mock_task
with app.test_request_context(
"/api/webhooks/agents/tok",
method="POST",
json={},
):
listener = AgentWebhookListener()
response = listener._enqueue_webhook_task("agent1", {}, "POST")
assert response.status_code == 200
assert response.json["task_id"] == "task_empty"
def test_get_empty_query_string(self, app):
"""GET request with no query params produces an empty payload dict."""
from application.api.user.agents.webhooks import AgentWebhookListener
mock_task = Mock()
mock_task.id = "task_noqs"
with patch(
"application.api.user.agents.webhooks.process_agent_webhook"
) as mock_process:
mock_process.apply_async.return_value = mock_task
with app.test_request_context(
"/api/webhooks/agents/tok",
method="GET",
):
listener = AgentWebhookListener()
response = listener.get(
webhook_token="tok",
agent={"_id": uuid.uuid4().hex},
agent_id_str="agentXYZ",
)
assert response.status_code == 200
call_kwargs = mock_process.apply_async.call_args[1]
# apply_async wraps task args under ``kwargs=``.
assert call_kwargs["kwargs"]["payload"] == {}
def test_enqueue_returns_task_id_in_response(self, app):
"""Success response body includes task_id from the Celery task."""
from application.api.user.agents.webhooks import AgentWebhookListener
mock_task = Mock()
mock_task.id = "celery-task-99"
with patch(
"application.api.user.agents.webhooks.process_agent_webhook"
) as mock_process:
mock_process.apply_async.return_value = mock_task
with app.test_request_context(
"/api/webhooks/agents/tok",
method="POST",
json={"k": "v"},
):
listener = AgentWebhookListener()
response = listener._enqueue_webhook_task("a1", {"k": "v"}, "POST")
assert response.json["success"] is True
assert response.json["task_id"] == "celery-task-99"
def test_enqueue_error_returns_500_with_message(self, app):
"""Queue failure returns 500 with a human-readable message."""
from application.api.user.agents.webhooks import AgentWebhookListener
with patch(
"application.api.user.agents.webhooks.process_agent_webhook"
) as mock_process:
mock_process.apply_async.side_effect = RuntimeError("celery is down")
with app.test_request_context(
"/api/webhooks/agents/tok",
method="POST",
json={"x": 1},
):
listener = AgentWebhookListener()
response = listener._enqueue_webhook_task("a1", {"x": 1}, "POST")
assert response.status_code == 500
assert response.json["success"] is False
assert "message" in response.json
# ---------------------------------------------------------------------------
# Real-PG tests for AgentWebhook (get) and AgentWebhookListener
# ---------------------------------------------------------------------------
@contextmanager
def _patch_webhooks_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.agents.webhooks.db_session", _yield
), patch(
"application.api.user.agents.webhooks.db_readonly", _yield
):
yield
@contextmanager
def _patch_base_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.base.db_readonly", _yield
), patch(
"application.api.user.base.db_session", _yield
):
yield
class TestAgentWebhookGet:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.webhooks import AgentWebhook
with app.test_request_context("/api/agent_webhook?id=x"):
from flask import request
request.decoded_token = None
response = AgentWebhook().get()
assert response.status_code == 401
def test_returns_400_missing_id(self, app):
from application.api.user.agents.webhooks import AgentWebhook
with app.test_request_context("/api/agent_webhook"):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentWebhook().get()
assert response.status_code == 400
def test_returns_404_missing_agent(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhook
with _patch_webhooks_db(pg_conn), app.test_request_context(
"/api/agent_webhook?id=00000000-0000-0000-0000-000000000000"
):
from flask import request
request.decoded_token = {"sub": "u"}
response = AgentWebhook().get()
assert response.status_code == 404
def test_generates_webhook_url(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhook
from application.storage.db.repositories.agents import AgentsRepository
user = "u-wh"
agent = AgentsRepository(pg_conn).create(user, "a", "published")
with _patch_webhooks_db(pg_conn), patch(
"application.api.user.agents.webhooks.settings.API_URL",
"https://api.test",
), app.test_request_context(
f"/api/agent_webhook?id={agent['id']}"
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentWebhook().get()
assert response.status_code == 200
assert response.json["webhook_url"].startswith(
"https://api.test/api/webhooks/agents/"
)
def test_reuses_existing_webhook_token(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhook
from application.storage.db.repositories.agents import AgentsRepository
user = "u-wh-reuse"
agent = AgentsRepository(pg_conn).create(
user, "a", "published", incoming_webhook_token="existing-tok",
)
with _patch_webhooks_db(pg_conn), patch(
"application.api.user.agents.webhooks.settings.API_URL",
"https://api.test",
), app.test_request_context(
f"/api/agent_webhook?id={agent['id']}"
):
from flask import request
request.decoded_token = {"sub": user}
response = AgentWebhook().get()
assert "existing-tok" in response.json["webhook_url"]
class TestAgentWebhookListener:
def test_post_valid_enqueues_task(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhookListener
from application.storage.db.repositories.agents import AgentsRepository
from unittest.mock import MagicMock
user = "u-wh-enq"
agent = AgentsRepository(pg_conn).create(
user, "a", "published", incoming_webhook_token="tk-enq",
)
fake_task = MagicMock(id="task-post")
with patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/webhooks/agents/tk-enq", method="POST",
json={"event": "x"},
):
listener = AgentWebhookListener()
# Call directly with manually-injected kwargs (bypass decorator)
response = listener.post(
webhook_token="tk-enq",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 200
assert response.json["task_id"] == "task-post"
def test_get_collects_query_params(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhookListener
from application.storage.db.repositories.agents import AgentsRepository
from unittest.mock import MagicMock
user = "u-wh-get"
agent = AgentsRepository(pg_conn).create(
user, "a", "published", incoming_webhook_token="tk-get",
)
fake_task = MagicMock(id="task-get")
with patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
return_value=fake_task,
), app.test_request_context(
"/api/webhooks/agents/tk-get?foo=bar&baz=42"
):
listener = AgentWebhookListener()
response = listener.get(
webhook_token="tk-get",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 200
@@ -0,0 +1,407 @@
"""Idempotency-Key behavior on the agent webhook listener route."""
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.agents.webhooks.db_session", _yield
), patch(
"application.api.user.agents.webhooks.db_readonly", _yield
):
yield
def _seed_agent(pg_conn, user="u", token="tk", **kw):
from application.storage.db.repositories.agents import AgentsRepository
return AgentsRepository(pg_conn).create(
user, "a", "published", incoming_webhook_token=token, **kw,
)
def _apply_async_mock():
"""Mock for ``process_agent_webhook.apply_async``; ``task.id`` mirrors the predetermined id."""
def _side_effect(*args, **kwargs):
return MagicMock(id=kwargs.get("task_id") or "auto-task-id")
return MagicMock(side_effect=_side_effect)
class TestWebhookIdempotency:
def test_no_header_enqueues_normally(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-noh", token="tk-noh")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-noh", method="POST",
json={"event": "x"},
):
listener = AgentWebhookListener()
response = listener.post(
webhook_token="tk-noh",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 200
assert apply_mock.call_count == 1
def test_header_first_post_records_row(self, app, pg_conn):
from sqlalchemy import text
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-first", token="tk-first")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-first", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "key-abc"},
):
listener = AgentWebhookListener()
response = listener.post(
webhook_token="tk-first",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 200
assert apply_mock.call_count == 1
predetermined_id = apply_mock.call_args.kwargs["task_id"]
assert response.json["task_id"] == predetermined_id
# Stored under the *scoped* form ``"{agent_id}:{key}"`` so two
# agents sharing the same raw header don't collapse on PK.
scoped_key = f"{agent['id']}:key-abc"
row = pg_conn.execute(
text("SELECT task_id, agent_id FROM webhook_dedup WHERE idempotency_key = :k"),
{"k": scoped_key},
).fetchone()
assert row is not None
assert row[0] == predetermined_id
assert str(row[1]) == str(agent["id"])
def test_header_forwards_idempotency_key_to_delay(self, app, pg_conn):
"""The Celery task body needs the key so ``with_idempotency`` can
record terminal status and ``_derive_source_id`` can pick it up.
"""
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-fwd", token="tk-fwd")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-fwd", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "key-fwd"},
):
listener = AgentWebhookListener()
listener.post(
webhook_token="tk-fwd",
agent=agent,
agent_id_str=str(agent["id"]),
)
# Worker sees the agent-scoped form so its dedup row is also
# agent-distinct.
scoped_key = f"{agent['id']}:key-fwd"
assert (
apply_mock.call_args.kwargs["kwargs"]["idempotency_key"]
== scoped_key
)
def test_same_header_second_post_returns_cached(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-rep", token="tk-rep")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
):
with app.test_request_context(
"/api/webhooks/agents/tk-rep", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "key-rep"},
):
listener = AgentWebhookListener()
first = listener.post(
webhook_token="tk-rep",
agent=agent,
agent_id_str=str(agent["id"]),
)
with app.test_request_context(
"/api/webhooks/agents/tk-rep", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "key-rep"},
):
listener = AgentWebhookListener()
second = listener.post(
webhook_token="tk-rep",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json == second.json
assert apply_mock.call_count == 1
def test_concurrent_same_key_only_one_apply_async(self, app, pg_engine):
"""Race test (M3): N parallel webhook POSTs with same key → only ONE apply_async.
Uses ``pg_engine`` so each thread checks out its own DB connection
(sharing a single Connection serializes at the driver level).
"""
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from application.api.user.agents.webhooks import AgentWebhookListener
from application.storage.db.repositories.agents import AgentsRepository
with pg_engine.begin() as conn:
agent = AgentsRepository(conn).create(
"u-race", "a", "published", incoming_webhook_token="tk-race",
)
apply_mock = _apply_async_mock()
@contextmanager
def _engine_session():
with pg_engine.begin() as conn:
yield conn
@contextmanager
def _engine_readonly():
with pg_engine.connect() as conn:
yield conn
def fire(idx):
# Patches sit outside the thread pool (see below); only the
# per-thread Flask request context is set up inside.
with app.test_request_context(
"/api/webhooks/agents/tk-race", method="POST",
json={"event": idx},
headers={"Idempotency-Key": "wh-race"},
):
listener = AgentWebhookListener()
return listener.post(
webhook_token="tk-race",
agent=agent,
agent_id_str=str(agent["id"]),
)
# ``unittest.mock.patch`` is not thread-safe; set up
# module-attribute patches once before fanning out so every
# thread sees the mock instead of racing on save/restore.
with patch(
"application.api.user.agents.webhooks.db_session",
_engine_session,
), patch(
"application.api.user.agents.webhooks.db_readonly",
_engine_readonly,
), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), ThreadPoolExecutor(max_workers=8) as ex:
responses = list(ex.map(fire, range(8)))
assert all(r.status_code == 200 for r in responses)
assert apply_mock.call_count == 1
ids = {r.json["task_id"] for r in responses}
assert len(ids) == 1
assert "deduplicated" not in ids
def test_same_key_different_agent_does_not_collide(self, app, pg_conn):
"""Idempotency keys are now scoped by ``agent_id`` — two agents
sending the same raw header each get their own dedup row, both
requests enqueue work, and the responses carry distinct
``task_id``s. (Pre-fix, the second agent's request was silently
deduplicated against the first agent's row.)
"""
from sqlalchemy import text as sql_text
from application.api.user.agents.webhooks import AgentWebhookListener
agent_a = _seed_agent(pg_conn, user="u-a", token="tk-a")
agent_b = _seed_agent(pg_conn, user="u-b", token="tk-b")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
):
with app.test_request_context(
"/api/webhooks/agents/tk-a", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "global-key"},
):
listener = AgentWebhookListener()
first = listener.post(
webhook_token="tk-a",
agent=agent_a,
agent_id_str=str(agent_a["id"]),
)
with app.test_request_context(
"/api/webhooks/agents/tk-b", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "global-key"},
):
listener = AgentWebhookListener()
second = listener.post(
webhook_token="tk-b",
agent=agent_b,
agent_id_str=str(agent_b["id"]),
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json["task_id"] != second.json["task_id"]
assert apply_mock.call_count == 2
# And there are two ``webhook_dedup`` rows: one per agent scope.
rows = pg_conn.execute(
sql_text(
"SELECT idempotency_key, agent_id FROM webhook_dedup "
"WHERE idempotency_key LIKE :pat ORDER BY idempotency_key"
),
{"pat": "%:global-key"},
).fetchall()
assert len(rows) == 2
scopes = {str(r[1]) for r in rows}
assert scopes == {str(agent_a["id"]), str(agent_b["id"])}
def test_empty_header_treated_as_absent(self, app, pg_conn):
from sqlalchemy import text
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-empty", token="tk-empty")
apply_mock = _apply_async_mock()
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-empty", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": ""},
):
listener = AgentWebhookListener()
response = listener.post(
webhook_token="tk-empty",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 200
assert apply_mock.call_count == 1
count = pg_conn.execute(
text("SELECT count(*) FROM webhook_dedup")
).scalar()
assert count == 0
def test_oversized_header_rejected_with_400(self, app, pg_conn):
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-big", token="tk-big")
oversized = "x" * 257
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
) as mock_apply, app.test_request_context(
"/api/webhooks/agents/tk-big", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": oversized},
):
listener = AgentWebhookListener()
response = listener.post(
webhook_token="tk-big",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert response.status_code == 400
assert mock_apply.call_count == 0
def test_stale_dedup_row_does_not_block_new_work(self, app, pg_conn):
"""Regression for the TTL fail-shut bug: a >24h-old dedup row
must not silently drop a new request. Pre-fix, the second POST
returned ``task_id="deduplicated"`` and never enqueued.
"""
from sqlalchemy import text
from application.api.user.agents.webhooks import AgentWebhookListener
agent = _seed_agent(pg_conn, user="u-stale", token="tk-stale")
apply_mock = _apply_async_mock()
# First POST creates a dedup row.
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-stale", method="POST",
json={"event": "x"},
headers={"Idempotency-Key": "stale-key"},
):
listener = AgentWebhookListener()
first = listener.post(
webhook_token="tk-stale",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert first.status_code == 200
first_task_id = first.json["task_id"]
assert first_task_id != "deduplicated"
# Backdate the row so it looks 25h old.
scoped_key = f"{agent['id']}:stale-key"
pg_conn.execute(
text(
"UPDATE webhook_dedup SET created_at = "
"clock_timestamp() - make_interval(hours => 25) "
"WHERE idempotency_key = :k"
),
{"k": scoped_key},
)
# Second POST with the same key must enqueue again, not silently dedup.
with _patch_db(pg_conn), patch(
"application.api.user.agents.webhooks.process_agent_webhook.apply_async",
apply_mock,
), app.test_request_context(
"/api/webhooks/agents/tk-stale", method="POST",
json={"event": "x2"},
headers={"Idempotency-Key": "stale-key"},
):
listener = AgentWebhookListener()
second = listener.post(
webhook_token="tk-stale",
agent=agent,
agent_id_str=str(agent["id"]),
)
assert second.status_code == 200
assert second.json["task_id"] != "deduplicated"
assert second.json["task_id"] != first_task_id
assert apply_mock.call_count == 2
File diff suppressed because it is too large Load Diff
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()
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
"""Tests for application.api.user.agents.sharing module."""
import uuid
from unittest.mock import Mock, patch
import pytest
from flask import Flask
pytestmark = pytest.mark.skip(
reason="Asserts Mongo-era agents_collection call shapes; needs PG repository-based rewrite. "
"Tracked as migration debt."
)
@pytest.fixture
def app():
app = Flask(__name__)
return app
# ---------------------------------------------------------------------------
# SharedAgent (GET /shared_agent)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSharedAgent:
def test_returns_400_missing_token(self, app):
from application.api.user.agents.sharing import SharedAgent
with app.test_request_context("/api/shared_agent"):
response = SharedAgent().get()
assert response.status_code == 400
def test_returns_404_agent_not_found(self, app):
from application.api.user.agents.sharing import SharedAgent
mock_col = Mock()
mock_col.find_one.return_value = None
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context("/api/shared_agent?token=abc123"):
response = SharedAgent().get()
assert response.status_code == 404
def test_returns_shared_agent_data(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Shared Agent",
"description": "A shared agent",
"chunks": "5",
"retriever": "classic",
"prompt_id": "default",
"tools": [],
"agent_type": "classic",
"status": "published",
"shared_publicly": True,
"shared_token": "abc123",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
):
with app.test_request_context("/api/shared_agent?token=abc123"):
from flask import request
# No decoded_token -> anonymous access
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
data = response.json
assert data["id"] == str(agent_id)
assert data["name"] == "Shared Agent"
assert data["shared"] is True
def test_adds_to_shared_with_me_for_different_user(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"tools": [],
"shared_publicly": True,
"shared_token": "abc123",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
mock_ensure = Mock(return_value={"user_id": "user2"})
mock_users_col = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
), patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.users_collection", mock_users_col
):
with app.test_request_context("/api/shared_agent?token=abc123"):
from flask import request
request.decoded_token = {"sub": "user2"}
response = SharedAgent().get()
assert response.status_code == 200
mock_ensure.assert_called_once_with("user2")
mock_users_col.update_one.assert_called_once()
def test_does_not_add_to_shared_for_owner(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"tools": [],
"shared_publicly": True,
"shared_token": "abc123",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
mock_ensure = Mock()
mock_users_col = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
), patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.users_collection", mock_users_col
):
with app.test_request_context("/api/shared_agent?token=abc123"):
from flask import request
request.decoded_token = {"sub": "owner1"}
response = SharedAgent().get()
assert response.status_code == 200
mock_ensure.assert_not_called()
mock_users_col.update_one.assert_not_called()
def test_enriches_tool_names(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
tool_id = str(uuid.uuid4().hex)
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"tools": [tool_id],
"shared_publicly": True,
"shared_token": "tok",
}
mock_tools_col = Mock()
mock_tools_col.find_one.return_value = {
"_id": tool_id,
"name": "calculator",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.user_tools_collection", mock_tools_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
):
with app.test_request_context("/api/shared_agent?token=tok"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
assert response.json["tools"] == ["calculator"]
def test_handles_source_dbref(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
source_id = uuid.uuid4().hex
source_ref = uuid.uuid4().hex # TODO: was DBRef("sources", source_id)
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"source": source_ref,
"tools": [],
"shared_publicly": True,
"shared_token": "tok",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
mock_db.dereference.return_value = {"_id": source_id}
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
):
with app.test_request_context("/api/shared_agent?token=tok"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
assert response.json["source"] == str(source_id)
def test_returns_400_on_exception(self, app):
from application.api.user.agents.sharing import SharedAgent
mock_col = Mock()
mock_col.find_one.side_effect = Exception("DB error")
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context("/api/shared_agent?token=tok"):
response = SharedAgent().get()
assert response.status_code == 400
def test_tool_enrichment_handles_missing_tool(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
tool_id = str(uuid.uuid4().hex)
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"tools": [tool_id],
"shared_publicly": True,
"shared_token": "tok",
}
mock_tools_col = Mock()
mock_tools_col.find_one.return_value = None
mock_resolve = Mock(return_value=[])
mock_db = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.user_tools_collection", mock_tools_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
):
with app.test_request_context("/api/shared_agent?token=tok"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
# Missing tools are skipped
assert response.json["tools"] == []
def test_image_url_generated_when_present(self, app):
from application.api.user.agents.sharing import SharedAgent
agent_id = uuid.uuid4().hex
mock_agents_col = Mock()
mock_agents_col.find_one.return_value = {
"_id": agent_id,
"user": "owner1",
"name": "Agent",
"image": "path/to/img.png",
"tools": [],
"shared_publicly": True,
"shared_token": "tok",
}
mock_resolve = Mock(return_value=[])
mock_db = Mock()
mock_generate = Mock(return_value="http://example.com/img.png")
with patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.db", mock_db
), patch(
"application.api.user.agents.sharing.generate_image_url", mock_generate
):
with app.test_request_context("/api/shared_agent?token=tok"):
from flask import request
request.decoded_token = None
response = SharedAgent().get()
assert response.status_code == 200
assert response.json["image"] == "http://example.com/img.png"
mock_generate.assert_called_once_with("path/to/img.png")
# ---------------------------------------------------------------------------
# SharedAgents (GET /shared_agents)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSharedAgents:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.sharing import SharedAgents
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = None
response = SharedAgents().get()
assert response.status_code == 401
def test_returns_shared_agents_list(self, app):
from application.api.user.agents.sharing import SharedAgents
agent_id = uuid.uuid4().hex
mock_ensure = Mock(
return_value={
"user_id": "user1",
"agent_preferences": {
"shared_with_me": [str(agent_id)],
"pinned": [str(agent_id)],
},
}
)
mock_agents_col = Mock()
mock_agents_col.find.return_value = [
{
"_id": agent_id,
"name": "Shared Agent",
"description": "desc",
"tools": [],
"agent_type": "classic",
"status": "published",
"shared_publicly": True,
"shared_token": "tok123",
}
]
mock_resolve = Mock(return_value=[])
mock_users_col = Mock()
with patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.users_collection", mock_users_col
):
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SharedAgents().get()
assert response.status_code == 200
data = response.json
assert len(data) == 1
assert data[0]["name"] == "Shared Agent"
assert data[0]["pinned"] is True
def test_removes_stale_shared_ids(self, app):
from application.api.user.agents.sharing import SharedAgents
stale_id = str(uuid.uuid4().hex)
mock_ensure = Mock(
return_value={
"user_id": "user1",
"agent_preferences": {
"shared_with_me": [stale_id],
"pinned": [],
},
}
)
mock_agents_col = Mock()
mock_agents_col.find.return_value = [] # None found
mock_users_col = Mock()
with patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.users_collection", mock_users_col
):
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SharedAgents().get()
assert response.status_code == 200
mock_users_col.update_one.assert_called_once()
call_args = mock_users_col.update_one.call_args
assert stale_id in call_args[0][1]["$pullAll"][
"agent_preferences.shared_with_me"
]
def test_returns_empty_when_no_shared_ids(self, app):
from application.api.user.agents.sharing import SharedAgents
mock_ensure = Mock(
return_value={
"user_id": "user1",
"agent_preferences": {"shared_with_me": [], "pinned": []},
}
)
mock_agents_col = Mock()
mock_agents_col.find.return_value = []
with patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
):
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SharedAgents().get()
assert response.status_code == 200
assert response.json == []
def test_returns_400_on_exception(self, app):
from application.api.user.agents.sharing import SharedAgents
mock_ensure = Mock(side_effect=Exception("DB error"))
with patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
):
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SharedAgents().get()
assert response.status_code == 400
def test_image_url_generated(self, app):
from application.api.user.agents.sharing import SharedAgents
agent_id = uuid.uuid4().hex
mock_ensure = Mock(
return_value={
"user_id": "user1",
"agent_preferences": {
"shared_with_me": [str(agent_id)],
"pinned": [],
},
}
)
mock_agents_col = Mock()
mock_agents_col.find.return_value = [
{
"_id": agent_id,
"name": "Agent",
"image": "path.png",
"tools": [],
"shared_publicly": True,
}
]
mock_resolve = Mock(return_value=[])
mock_generate = Mock(return_value="http://example.com/path.png")
with patch(
"application.api.user.agents.sharing.ensure_user_doc", mock_ensure
), patch(
"application.api.user.agents.sharing.agents_collection", mock_agents_col
), patch(
"application.api.user.agents.sharing.resolve_tool_details", mock_resolve
), patch(
"application.api.user.agents.sharing.generate_image_url", mock_generate
), patch(
"application.api.user.agents.sharing.users_collection", Mock()
):
with app.test_request_context("/api/shared_agents"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SharedAgents().get()
assert response.status_code == 200
assert response.json[0]["image"] == "http://example.com/path.png"
# ---------------------------------------------------------------------------
# ShareAgent (PUT /share_agent)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestShareAgent:
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": "abc", "shared": True},
):
from flask import request
request.decoded_token = None
response = ShareAgent().put()
assert response.status_code == 401
def test_returns_400_missing_json_body(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent",
method="PUT",
content_type="application/json",
data=b"{}",
):
from flask import request
request.decoded_token = {"sub": "user1"}
# Empty JSON object -> no id, no shared -> 400
response = ShareAgent().put()
assert response.status_code == 400
assert response.json["success"] is False
def test_returns_400_missing_id(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={"shared": True},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 400
def test_returns_400_missing_shared_param(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": str(uuid.uuid4().hex)},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 400
def test_returns_400_invalid_agent_id(self, app):
from application.api.user.agents.sharing import ShareAgent
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": "invalid-oid", "shared": True},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 400
def test_returns_404_agent_not_found(self, app):
from application.api.user.agents.sharing import ShareAgent
mock_col = Mock()
mock_col.find_one.return_value = None
agent_id = str(uuid.uuid4().hex)
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={"id": agent_id, "shared": True},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 404
def test_shares_agent_success(self, app):
from application.api.user.agents.sharing import ShareAgent
agent_id = uuid.uuid4().hex
mock_col = Mock()
mock_col.find_one.return_value = {
"_id": agent_id,
"user": "user1",
}
mock_col.update_one.return_value = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={
"id": str(agent_id),
"shared": True,
"username": "TestUser",
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 200
data = response.json
assert data["success"] is True
assert data["shared_token"] is not None
mock_col.update_one.assert_called_once()
def test_unshares_agent_success(self, app):
from application.api.user.agents.sharing import ShareAgent
agent_id = uuid.uuid4().hex
mock_col = Mock()
mock_col.find_one.return_value = {
"_id": agent_id,
"user": "user1",
}
mock_col.update_one.return_value = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={
"id": str(agent_id),
"shared": False,
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 200
data = response.json
assert data["success"] is True
assert data["shared_token"] is None
def test_returns_400_on_db_exception(self, app):
from application.api.user.agents.sharing import ShareAgent
agent_id = uuid.uuid4().hex
mock_col = Mock()
mock_col.find_one.return_value = {
"_id": agent_id,
"user": "user1",
}
mock_col.update_one.side_effect = Exception("DB error")
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={
"id": str(agent_id),
"shared": True,
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 400
def test_share_with_username(self, app):
from application.api.user.agents.sharing import ShareAgent
agent_id = uuid.uuid4().hex
mock_col = Mock()
mock_col.find_one.return_value = {
"_id": agent_id,
"user": "user1",
}
mock_col.update_one.return_value = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={
"id": str(agent_id),
"shared": True,
"username": "SharedByUser",
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 200
# Verify the update call includes shared_metadata with username
update_call = mock_col.update_one.call_args[0][1]["$set"]
assert update_call["shared_metadata"]["shared_by"] == "SharedByUser"
assert update_call["shared_publicly"] is True
assert "shared_token" in update_call
def test_shared_false_explicitly(self, app):
from application.api.user.agents.sharing import ShareAgent
agent_id = uuid.uuid4().hex
mock_col = Mock()
mock_col.find_one.return_value = {
"_id": agent_id,
"user": "user1",
}
mock_col.update_one.return_value = Mock()
with patch(
"application.api.user.agents.sharing.agents_collection", mock_col
):
with app.test_request_context(
"/api/share_agent",
method="PUT",
json={
"id": str(agent_id),
"shared": False,
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = ShareAgent().put()
assert response.status_code == 200
update_call = mock_col.update_one.call_args[0][1]
assert update_call["$set"]["shared_publicly"] is False
assert update_call["$set"]["shared_token"] is None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
import datetime
import io
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
from werkzeug.datastructures import FileStorage
@contextmanager
def _patch_base_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.base.db_session", _yield
), patch(
"application.api.user.base.db_readonly", _yield
):
yield
@pytest.mark.unit
class TestTimeRangeGenerators:
pass
def test_generate_minute_range(self):
from application.api.user.base import generate_minute_range
start = datetime.datetime(2024, 1, 1, 10, 0, 0)
end = datetime.datetime(2024, 1, 1, 10, 5, 0)
result = generate_minute_range(start, end)
assert len(result) == 6
assert "2024-01-01 10:00:00" in result
assert "2024-01-01 10:05:00" in result
assert all(val == 0 for val in result.values())
def test_generate_hourly_range(self):
from application.api.user.base import generate_hourly_range
start = datetime.datetime(2024, 1, 1, 10, 0, 0)
end = datetime.datetime(2024, 1, 1, 15, 0, 0)
result = generate_hourly_range(start, end)
assert len(result) == 6
assert "2024-01-01 10:00" in result
assert "2024-01-01 15:00" in result
assert all(val == 0 for val in result.values())
def test_generate_date_range(self):
from application.api.user.base import generate_date_range
start = datetime.date(2024, 1, 1)
end = datetime.date(2024, 1, 5)
result = generate_date_range(start, end)
assert len(result) == 5
assert "2024-01-01" in result
assert "2024-01-05" in result
assert all(val == 0 for val in result.values())
def test_single_minute_range(self):
from application.api.user.base import generate_minute_range
time = datetime.datetime(2024, 1, 1, 10, 30, 0)
result = generate_minute_range(time, time)
assert len(result) == 1
assert "2024-01-01 10:30:00" in result
@pytest.mark.unit
class TestEnsureUserDoc:
pass
@pytest.mark.unit
class TestResolveToolDetails:
pass
def test_empty_tool_ids_list(self, mock_mongo_db):
from application.api.user.base import resolve_tool_details
result = resolve_tool_details([])
assert result == []
@pytest.mark.unit
class TestGetVectorStore:
pass
@patch("application.api.user.base.VectorCreator.create_vectorstore")
def test_creates_vector_store(self, mock_create):
from application.api.user.base import get_vector_store
mock_store = Mock()
mock_create.return_value = mock_store
source_id = "test_source_123"
result = get_vector_store(source_id)
assert result == mock_store
mock_create.assert_called_once()
args, kwargs = mock_create.call_args
assert kwargs.get("source_id") == source_id
@pytest.mark.unit
class TestHandleImageUpload:
pass
def test_returns_existing_url_when_no_file(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.test_request_context():
mock_request = Mock()
mock_request.files = {}
mock_storage = Mock()
existing_url = "existing/path/image.jpg"
url, error = handle_image_upload(
mock_request, existing_url, "user123", mock_storage
)
assert url == existing_url
assert error is None
def test_uploads_new_image(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.test_request_context():
mock_file = FileStorage(
stream=io.BytesIO(b"fake image data"), filename="test_image.png"
)
mock_request = Mock()
mock_request.files = {"image": mock_file}
mock_storage = Mock()
mock_storage.save_file.return_value = {"success": True}
url, error = handle_image_upload(
mock_request, "old_url", "user123", mock_storage
)
assert error is None
assert url is not None
assert "test_image.png" in url
assert "user123" in url
mock_storage.save_file.assert_called_once()
def test_ignores_empty_filename(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.test_request_context():
mock_file = Mock()
mock_file.filename = ""
mock_request = Mock()
mock_request.files = {"image": mock_file}
mock_storage = Mock()
existing_url = "existing.jpg"
url, error = handle_image_upload(
mock_request, existing_url, "user123", mock_storage
)
assert url == existing_url
assert error is None
mock_storage.save_file.assert_not_called()
def test_handles_upload_error(self, flask_app):
from application.api.user.base import handle_image_upload
with flask_app.app_context():
mock_file = FileStorage(stream=io.BytesIO(b"data"), filename="test.png")
mock_request = Mock()
mock_request.files = {"image": mock_file}
mock_storage = Mock()
mock_storage.save_file.side_effect = Exception("Storage error")
url, error = handle_image_upload(
mock_request, "old.jpg", "user123", mock_storage
)
assert url is None
assert error is not None
assert error.status_code == 400
@pytest.mark.unit
class TestRequireAgentDecorator:
pass
def test_returns_400_for_missing_token(self, flask_app):
from application.api.user.base import require_agent
with flask_app.app_context():
@require_agent
def test_func(webhook_token=None, agent=None, agent_id_str=None):
return {"success": True}
result = test_func()
assert result.status_code == 400
assert result.json["success"] is False
# ---------------------------------------------------------------------------
# Real PG tests: ensure_user_doc, resolve_tool_details, require_agent
# ---------------------------------------------------------------------------
class TestEnsureUserDocPgConn:
def test_creates_new_user_doc(self, pg_conn):
from application.api.user.base import ensure_user_doc
with _patch_base_db(pg_conn):
doc = ensure_user_doc("brand-new-user")
assert doc["user_id"] == "brand-new-user"
prefs = doc["agent_preferences"]
assert prefs.get("pinned") == []
assert prefs.get("shared_with_me") == []
def test_preserves_existing_prefs(self, pg_conn):
from application.api.user.base import ensure_user_doc
from application.storage.db.repositories.users import UsersRepository
user = "existing-user"
UsersRepository(pg_conn).upsert(user)
UsersRepository(pg_conn).add_pinned(user, "agent-abc")
with _patch_base_db(pg_conn):
doc = ensure_user_doc(user)
assert "agent-abc" in doc["agent_preferences"]["pinned"]
assert doc["agent_preferences"]["shared_with_me"] == []
class TestResolveToolDetailsPgConn:
def test_empty_list_returns_empty(self, pg_conn):
from application.api.user.base import resolve_tool_details
with _patch_base_db(pg_conn):
assert resolve_tool_details([]) == []
def test_none_entries_filtered_out(self, pg_conn):
from application.api.user.base import resolve_tool_details
with _patch_base_db(pg_conn):
assert resolve_tool_details([None, ""]) == []
def test_resolves_known_uuid_ids(self, pg_conn):
from application.api.user.base import resolve_tool_details
from application.storage.db.repositories.user_tools import (
UserToolsRepository,
)
tool = UserToolsRepository(pg_conn).create(
"u", "my_tool", display_name="My Tool",
custom_name="Custom",
description="x",
)
with _patch_base_db(pg_conn):
got = resolve_tool_details([str(tool["id"])])
assert len(got) == 1
assert got[0]["name"] == "my_tool"
assert got[0]["display_name"] == "Custom"
def test_unknown_ids_skipped(self, pg_conn):
from application.api.user.base import resolve_tool_details
with _patch_base_db(pg_conn):
got = resolve_tool_details(
["00000000-0000-0000-0000-000000000000"]
)
assert got == []
def test_legacy_ids_lookup(self, pg_conn):
from application.api.user.base import resolve_tool_details
from application.storage.db.repositories.user_tools import (
UserToolsRepository,
)
tool = UserToolsRepository(pg_conn).create(
"u", "legacy_tool",
display_name="Legacy",
legacy_mongo_id="507f1f77bcf86cd799439011",
)
_ = tool
with _patch_base_db(pg_conn):
got = resolve_tool_details(["507f1f77bcf86cd799439011"])
assert len(got) == 1
assert got[0]["name"] == "legacy_tool"
class TestRequireAgentPgConn:
def test_returns_404_invalid_token(self, pg_conn, flask_app):
from application.api.user.base import require_agent
@require_agent
def fn(webhook_token=None, agent=None, agent_id_str=None):
return {"ok": True}
with _patch_base_db(pg_conn), flask_app.app_context():
result = fn(webhook_token="bogus")
assert result.status_code == 404
def test_injects_agent_when_valid(self, pg_conn, flask_app):
from application.api.user.base import require_agent
from application.storage.db.repositories.agents import AgentsRepository
agent = AgentsRepository(pg_conn).create(
"owner", "wh-agent", "published",
incoming_webhook_token="webhook-123",
)
@require_agent
def fn(webhook_token=None, agent=None, agent_id_str=None):
return {"got": agent_id_str}
with _patch_base_db(pg_conn), flask_app.app_context():
result = fn(webhook_token="webhook-123")
assert result["got"] == str(agent["id"])
+771
View File
@@ -0,0 +1,771 @@
import uuid
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from flask import Flask
@pytest.fixture
def app():
app = Flask(__name__)
return app
@contextmanager
def _patch_conversations_db(conn):
@contextmanager
def _yield_conn():
yield conn
with patch(
"application.api.user.conversations.routes.db_session", _yield_conn
), patch(
"application.api.user.conversations.routes.db_readonly", _yield_conn
):
yield
def _seed_conversation(pg_conn, user_id, name="Test Conv"):
"""Create a conversation and return its PG uuid id as str."""
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
repo = ConversationsRepository(pg_conn)
conv = repo.create(user_id, name=name)
return str(conv["id"])
@pytest.mark.unit
class TestDeleteConversation:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import DeleteConversation
with app.test_request_context("/api/delete_conversation?id=abc"):
from flask import request
request.decoded_token = None
response = DeleteConversation().post()
assert response.status_code == 401
def test_returns_400_missing_id(self, app):
from application.api.user.conversations.routes import DeleteConversation
with app.test_request_context("/api/delete_conversation"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = DeleteConversation().post()
assert response.status_code == 400
@pytest.mark.unit
class TestDeleteAllConversations:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import DeleteAllConversations
with app.test_request_context("/api/delete_all_conversations"):
from flask import request
request.decoded_token = None
response = DeleteAllConversations().get()
assert response.status_code == 401
@pytest.mark.unit
class TestGetConversations:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import GetConversations
with app.test_request_context("/api/get_conversations"):
from flask import request
request.decoded_token = None
response = GetConversations().get()
assert response.status_code == 401
@pytest.mark.unit
class TestGetSingleConversation:
pass
def test_returns_400_missing_id(self, app):
from application.api.user.conversations.routes import GetSingleConversation
with app.test_request_context("/api/get_single_conversation"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = GetSingleConversation().get()
assert response.status_code == 400
@pytest.mark.unit
class TestUpdateConversationName:
pass
def test_returns_400_missing_fields(self, app):
from application.api.user.conversations.routes import UpdateConversationName
with app.test_request_context(
"/api/update_conversation_name",
method="POST",
json={"id": str(uuid.uuid4().hex[:24])},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = UpdateConversationName().post()
assert response.status_code == 400
@pytest.mark.unit
class TestSubmitFeedback:
pass
def test_returns_400_missing_fields(self, app):
from application.api.user.conversations.routes import SubmitFeedback
with app.test_request_context(
"/api/feedback",
method="POST",
json={"feedback": "LIKE"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = SubmitFeedback().post()
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Happy-path tests exercising real PG via the ephemeral pg_conn fixture.
# ---------------------------------------------------------------------------
class TestDeleteConversationHappy:
def test_deletes_existing_conversation(self, app, pg_conn):
from application.api.user.conversations.routes import DeleteConversation
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-del"
conv_id = _seed_conversation(pg_conn, user)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/delete_conversation?id={conv_id}"
):
from flask import request
request.decoded_token = {"sub": user}
response = DeleteConversation().post()
assert response.status_code == 200
assert response.json["success"] is True
# Gone
assert ConversationsRepository(pg_conn).get_any(conv_id, user) is None
def test_delete_nonexistent_still_returns_200(self, app, pg_conn):
"""get_any returns None, so delete is a no-op but endpoint succeeds."""
from application.api.user.conversations.routes import DeleteConversation
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/delete_conversation?id={uuid.uuid4()}"
):
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteConversation().post()
assert response.status_code == 200
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import DeleteConversation
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_session", _broken
), app.test_request_context("/api/delete_conversation?id=abc"):
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteConversation().post()
assert response.status_code == 400
class TestDeleteAllConversationsHappy:
def test_deletes_all_conversations(self, app, pg_conn):
from application.api.user.conversations.routes import (
DeleteAllConversations,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-delall"
_seed_conversation(pg_conn, user, name="a")
_seed_conversation(pg_conn, user, name="b")
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/delete_all_conversations"
):
from flask import request
request.decoded_token = {"sub": user}
response = DeleteAllConversations().get()
assert response.status_code == 200
assert ConversationsRepository(pg_conn).list_for_user(user) == []
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import (
DeleteAllConversations,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_session", _broken
), app.test_request_context("/api/delete_all_conversations"):
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteAllConversations().get()
assert response.status_code == 400
class TestGetConversationsHappy:
def test_returns_list_of_conversations(self, app, pg_conn):
from application.api.user.conversations.routes import GetConversations
user = "user-list"
c1 = _seed_conversation(pg_conn, user, name="one")
c2 = _seed_conversation(pg_conn, user, name="two")
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/get_conversations"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetConversations().get()
assert response.status_code == 200
ids = {c["id"] for c in response.json}
assert c1 in ids and c2 in ids
# agent_id, is_shared_usage, shared_token keys present
for c in response.json:
assert "agent_id" in c
assert "is_shared_usage" in c
assert "shared_token" in c
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import GetConversations
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_readonly", _broken
), app.test_request_context("/api/get_conversations"):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetConversations().get()
assert response.status_code == 400
class TestGetSingleConversationHappy:
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import (
GetSingleConversation,
)
with app.test_request_context("/api/get_single_conversation?id=x"):
from flask import request
request.decoded_token = None
response = GetSingleConversation().get()
assert response.status_code == 401
def test_returns_404_not_found(self, app, pg_conn):
from application.api.user.conversations.routes import (
GetSingleConversation,
)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/get_single_conversation?id={uuid.uuid4()}"
):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetSingleConversation().get()
assert response.status_code == 404
def test_returns_conversation_with_messages(self, app, pg_conn):
from application.api.user.conversations.routes import (
GetSingleConversation,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-get"
conv_id = _seed_conversation(pg_conn, user, name="chat")
# Append a message
ConversationsRepository(pg_conn).append_message(
conv_id,
{
"prompt": "hi",
"response": "hello",
"thought": None,
"sources": [],
"tool_calls": [],
"timestamp": None,
"model_id": None,
},
)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/get_single_conversation?id={conv_id}"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetSingleConversation().get()
assert response.status_code == 200
data = response.json
assert isinstance(data["queries"], list)
assert data["queries"][0]["prompt"] == "hi"
assert data["queries"][0]["response"] == "hello"
def test_returns_message_with_dict_feedback(self, app, pg_conn):
from application.api.user.conversations.routes import (
GetSingleConversation,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-fb"
conv_id = _seed_conversation(pg_conn, user, name="fb")
repo = ConversationsRepository(pg_conn)
repo.append_message(conv_id, {"prompt": "p", "response": "r"})
repo.set_feedback(
conv_id, 0, {"text": "like", "timestamp": "2024-01-01T00:00:00Z"}
)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/get_single_conversation?id={conv_id}"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetSingleConversation().get()
assert response.status_code == 200
q = response.json["queries"][0]
assert q["feedback"] == "like"
assert q["feedback_timestamp"] == "2024-01-01T00:00:00Z"
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import (
GetSingleConversation,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_readonly", _broken
), app.test_request_context("/api/get_single_conversation?id=abc"):
from flask import request
request.decoded_token = {"sub": "u"}
response = GetSingleConversation().get()
assert response.status_code == 400
@pytest.mark.unit
class TestGetMessageTail:
"""Tail-poll endpoint (``GET /api/messages/<id>/tail``) used by the
frontend to recover a placeholder/streaming row after a refresh.
"""
def _seed_in_flight_message(self, pg_conn, owner_user_id):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
conv_id = _seed_conversation(pg_conn, owner_user_id, name="streaming chat")
repo = ConversationsRepository(pg_conn)
msg = repo.reserve_message(
conv_id,
prompt="what's happening?",
placeholder_response=(
"Response was terminated prior to completion, try regenerating."
),
request_id=str(uuid.uuid4()),
status="streaming",
)
return conv_id, str(msg["id"])
def test_owner_can_tail(self, app, pg_conn):
from application.api.user.conversations.routes import GetMessageTail
owner = "user-owner"
_, msg_id = self._seed_in_flight_message(pg_conn, owner)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/messages/{msg_id}/tail"
):
from flask import request
request.decoded_token = {"sub": owner}
response = GetMessageTail().get(msg_id)
assert response.status_code == 200
assert response.json["status"] == "streaming"
assert response.json["message_id"] == msg_id
def test_shared_user_can_tail(self, app, pg_conn):
"""A user in ``conversations.shared_with`` must be able to tail
an in-flight placeholder. Without the shared-with predicate
here, ``get_single_conversation`` lets them load the row but
the tail-poll silently 404s and the in-flight bubble never
resolves on the shared user's side.
"""
from application.api.user.conversations.routes import GetMessageTail
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
owner = "user-owner-shared"
shared_user = "user-shared"
conv_id, msg_id = self._seed_in_flight_message(pg_conn, owner)
ConversationsRepository(pg_conn).add_shared_user(conv_id, shared_user)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/messages/{msg_id}/tail"
):
from flask import request
request.decoded_token = {"sub": shared_user}
response = GetMessageTail().get(msg_id)
assert response.status_code == 200
assert response.json["message_id"] == msg_id
def test_non_member_gets_404(self, app, pg_conn):
from application.api.user.conversations.routes import GetMessageTail
owner = "user-owner-private"
intruder = "user-intruder"
_, msg_id = self._seed_in_flight_message(pg_conn, owner)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/messages/{msg_id}/tail"
):
from flask import request
request.decoded_token = {"sub": intruder}
response = GetMessageTail().get(msg_id)
assert response.status_code == 404
def test_streaming_row_returns_partial_from_journal(self, app, pg_conn):
"""Mid-stream rows must rebuild from message_events, not return the placeholder."""
from application.api.user.conversations.routes import GetMessageTail
from application.storage.db.repositories.message_events import (
MessageEventsRepository,
)
owner = "user-tail-partial"
_, msg_id = self._seed_in_flight_message(pg_conn, owner)
events_repo = MessageEventsRepository(pg_conn)
events_repo.record(msg_id, 0, "message_id", {"type": "message_id"})
events_repo.record(msg_id, 1, "answer", {"type": "answer", "answer": "Hello"})
events_repo.record(msg_id, 2, "answer", {"type": "answer", "answer": ", world"})
events_repo.record(
msg_id, 3, "source", {"type": "source", "source": [{"id": "s1"}]}
)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/messages/{msg_id}/tail"
):
from flask import request
request.decoded_token = {"sub": owner}
response = GetMessageTail().get(msg_id)
assert response.status_code == 200
assert response.json["status"] == "streaming"
assert response.json["response"] == "Hello, world"
assert response.json["sources"] == [{"id": "s1"}]
assert "terminated prior to completion" not in (
response.json["response"] or ""
)
def test_streaming_row_with_empty_journal_returns_empty_response(
self, app, pg_conn
):
"""Empty journal returns empty response, not the placeholder."""
from application.api.user.conversations.routes import GetMessageTail
owner = "user-tail-empty"
_, msg_id = self._seed_in_flight_message(pg_conn, owner)
with _patch_conversations_db(pg_conn), app.test_request_context(
f"/api/messages/{msg_id}/tail"
):
from flask import request
request.decoded_token = {"sub": owner}
response = GetMessageTail().get(msg_id)
assert response.status_code == 200
assert response.json["status"] == "streaming"
assert response.json["response"] == ""
class TestUpdateConversationNameHappy:
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import (
UpdateConversationName,
)
with app.test_request_context(
"/api/update_conversation_name",
method="POST",
json={"id": "x", "name": "n"},
):
from flask import request
request.decoded_token = None
response = UpdateConversationName().post()
assert response.status_code == 401
def test_renames_conversation(self, app, pg_conn):
from application.api.user.conversations.routes import (
UpdateConversationName,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-rename"
conv_id = _seed_conversation(pg_conn, user, name="old")
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/update_conversation_name",
method="POST",
json={"id": conv_id, "name": "new"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateConversationName().post()
assert response.status_code == 200
got = ConversationsRepository(pg_conn).get_any(conv_id, user)
assert got["name"] == "new"
def test_rename_nonexistent_still_returns_200(self, app, pg_conn):
from application.api.user.conversations.routes import (
UpdateConversationName,
)
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/update_conversation_name",
method="POST",
json={"id": str(uuid.uuid4()), "name": "n"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateConversationName().post()
assert response.status_code == 200
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import (
UpdateConversationName,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_session", _broken
), app.test_request_context(
"/api/update_conversation_name",
method="POST",
json={"id": "x", "name": "n"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateConversationName().post()
assert response.status_code == 400
class TestSubmitFeedbackHappy:
def test_returns_401_unauthenticated(self, app):
from application.api.user.conversations.routes import SubmitFeedback
with app.test_request_context(
"/api/feedback",
method="POST",
json={
"feedback": "like",
"question_index": 0,
"conversation_id": "x",
},
):
from flask import request
request.decoded_token = None
response = SubmitFeedback().post()
assert response.status_code == 401
def test_submits_feedback(self, app, pg_conn):
from application.api.user.conversations.routes import SubmitFeedback
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-fb1"
conv_id = _seed_conversation(pg_conn, user, name="fb")
ConversationsRepository(pg_conn).append_message(
conv_id, {"prompt": "p", "response": "r"}
)
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/feedback",
method="POST",
json={
"feedback": "LIKE", # uppercase normalized to lowercase
"question_index": 0,
"conversation_id": conv_id,
},
):
from flask import request
request.decoded_token = {"sub": user}
response = SubmitFeedback().post()
assert response.status_code == 200
msgs = ConversationsRepository(pg_conn).get_messages(conv_id)
fb = msgs[0].get("feedback")
assert fb and fb.get("text") == "like"
def test_none_feedback_allowed(self, app, pg_conn):
from application.api.user.conversations.routes import SubmitFeedback
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "user-fb-none"
conv_id = _seed_conversation(pg_conn, user)
ConversationsRepository(pg_conn).append_message(
conv_id, {"prompt": "p", "response": "r"}
)
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/feedback",
method="POST",
json={
"feedback": None,
"question_index": 0,
"conversation_id": conv_id,
},
):
from flask import request
request.decoded_token = {"sub": user}
response = SubmitFeedback().post()
assert response.status_code == 200
def test_returns_404_for_missing_conversation(self, app, pg_conn):
from application.api.user.conversations.routes import SubmitFeedback
with _patch_conversations_db(pg_conn), app.test_request_context(
"/api/feedback",
method="POST",
json={
"feedback": "like",
"question_index": 0,
"conversation_id": str(uuid.uuid4()),
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = SubmitFeedback().post()
assert response.status_code == 404
def test_db_error_returns_400(self, app):
from application.api.user.conversations.routes import SubmitFeedback
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.conversations.routes.db_session", _broken
), app.test_request_context(
"/api/feedback",
method="POST",
json={
"feedback": "like",
"question_index": 0,
"conversation_id": "x",
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = SubmitFeedback().post()
assert response.status_code == 400
@@ -0,0 +1,13 @@
"""Tests covering exception-message sanitization in user routes.
Previously patched Mongo-shaped module attributes (agent_folders_collection
etc.) that no longer exist post-cutover. Scheduled for rewrite against the
new repository seams.
"""
import pytest
@pytest.mark.skip(reason="needs PG fixture rewrite - tracked separately")
def test_exception_sanitization_pending_pg_rewrite():
pass
+627
View File
@@ -0,0 +1,627 @@
import datetime
import uuid
from unittest.mock import Mock, patch
import pytest
from flask import Flask
pytestmark = pytest.mark.skip(
reason="Asserts Mongo-era agent_folders_collection call shapes; needs PG repository-based "
"rewrite. Tracked as migration debt."
)
@pytest.fixture
def app():
app = Flask(__name__)
return app
@pytest.mark.unit
class TestAgentFoldersGet:
def test_returns_folders(self, app):
from application.api.user.agents.folders import AgentFolders
now = datetime.datetime(2024, 6, 15, tzinfo=datetime.timezone.utc)
folder_id = uuid.uuid4().hex
mock_collection = Mock()
mock_collection.find.return_value = [
{
"_id": folder_id,
"name": "My Folder",
"parent_id": None,
"created_at": now,
"updated_at": now,
}
]
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context("/api/agents/folders/", method="GET"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolders().get()
assert response.status_code == 200
folders = response.json["folders"]
assert len(folders) == 1
assert folders[0]["id"] == str(folder_id)
assert folders[0]["name"] == "My Folder"
def test_returns_401_unauthenticated(self, app):
from application.api.user.agents.folders import AgentFolders
with app.test_request_context("/api/agents/folders/", method="GET"):
from flask import request
request.decoded_token = None
response = AgentFolders().get()
assert response.status_code == 401
@pytest.mark.unit
class TestAgentFoldersCreate:
def test_creates_folder(self, app):
from application.api.user.agents.folders import AgentFolders
inserted_id = uuid.uuid4().hex
mock_collection = Mock()
mock_collection.insert_one.return_value = Mock(inserted_id=inserted_id)
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context(
"/api/agents/folders/",
method="POST",
json={"name": "New Folder"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolders().post()
assert response.status_code == 201
assert response.json["id"] == str(inserted_id)
assert response.json["name"] == "New Folder"
def test_returns_400_missing_name(self, app):
from application.api.user.agents.folders import AgentFolders
with app.test_request_context(
"/api/agents/folders/",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolders().post()
assert response.status_code == 400
def test_validates_parent_folder_exists(self, app):
from application.api.user.agents.folders import AgentFolders
mock_collection = Mock()
mock_collection.find_one.return_value = None
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context(
"/api/agents/folders/",
method="POST",
json={"name": "Sub", "parent_id": str(uuid.uuid4().hex)},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolders().post()
assert response.status_code == 404
@pytest.mark.unit
class TestAgentFolderGet:
def test_returns_folder_with_agents_and_subfolders(self, app):
from application.api.user.agents.folders import AgentFolder
folder_id = uuid.uuid4().hex
agent_id = uuid.uuid4().hex
subfolder_id = uuid.uuid4().hex
mock_folders = Mock()
mock_folders.find_one.return_value = {
"_id": folder_id,
"name": "Folder",
"parent_id": None,
}
mock_folders.find.return_value = [
{"_id": subfolder_id, "name": "Subfolder"}
]
mock_agents = Mock()
mock_agents.find.return_value = [
{"_id": agent_id, "name": "Agent 1", "description": "Desc"}
]
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
), patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
f"/api/agents/folders/{folder_id}", method="GET"
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().get(str(folder_id))
assert response.status_code == 200
assert response.json["name"] == "Folder"
assert len(response.json["agents"]) == 1
assert len(response.json["subfolders"]) == 1
def test_returns_404_not_found(self, app):
from application.api.user.agents.folders import AgentFolder
mock_collection = Mock()
mock_collection.find_one.return_value = None
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context(
f"/api/agents/folders/{uuid.uuid4().hex}", method="GET"
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().get(str(uuid.uuid4().hex))
assert response.status_code == 404
@pytest.mark.unit
class TestAgentFolderUpdate:
def test_updates_folder_name(self, app):
from application.api.user.agents.folders import AgentFolder
folder_id = uuid.uuid4().hex
mock_collection = Mock()
mock_collection.update_one.return_value = Mock(matched_count=1)
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context(
f"/api/agents/folders/{folder_id}",
method="PUT",
json={"name": "Renamed"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().put(str(folder_id))
assert response.status_code == 200
assert response.json["success"] is True
def test_prevents_self_parent(self, app):
from application.api.user.agents.folders import AgentFolder
folder_id = str(uuid.uuid4().hex)
with app.test_request_context(
f"/api/agents/folders/{folder_id}",
method="PUT",
json={"parent_id": folder_id},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().put(folder_id)
assert response.status_code == 400
assert "own parent" in response.json["message"]
def test_returns_404_when_not_found(self, app):
from application.api.user.agents.folders import AgentFolder
mock_collection = Mock()
mock_collection.update_one.return_value = Mock(matched_count=0)
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_collection,
):
with app.test_request_context(
f"/api/agents/folders/{uuid.uuid4().hex}",
method="PUT",
json={"name": "X"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().put(str(uuid.uuid4().hex))
assert response.status_code == 404
@pytest.mark.unit
class TestAgentFolderDelete:
def test_deletes_folder_and_unsets_references(self, app):
from application.api.user.agents.folders import AgentFolder
folder_id = str(uuid.uuid4().hex)
mock_folders = Mock()
mock_folders.delete_one.return_value = Mock(deleted_count=1)
mock_agents = Mock()
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
), patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
f"/api/agents/folders/{folder_id}", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().delete(folder_id)
assert response.status_code == 200
mock_agents.update_many.assert_called_once()
mock_folders.update_many.assert_called_once()
mock_folders.delete_one.assert_called_once()
def test_returns_404_not_found(self, app):
from application.api.user.agents.folders import AgentFolder
mock_folders = Mock()
mock_folders.delete_one.return_value = Mock(deleted_count=0)
mock_agents = Mock()
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
), patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
f"/api/agents/folders/{uuid.uuid4().hex}", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().delete(str(uuid.uuid4().hex))
assert response.status_code == 404
@pytest.mark.unit
class TestMoveAgentToFolder:
def test_moves_agent_to_folder(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
agent_id = uuid.uuid4().hex
folder_id = uuid.uuid4().hex
mock_agents = Mock()
mock_agents.find_one.return_value = {"_id": agent_id, "user": "user1"}
mock_folders = Mock()
mock_folders.find_one.return_value = {"_id": folder_id}
with patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
), patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
):
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={
"agent_id": str(agent_id),
"folder_id": str(folder_id),
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = MoveAgentToFolder().post()
assert response.status_code == 200
mock_agents.update_one.assert_called_once()
def test_removes_agent_from_folder(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
agent_id = uuid.uuid4().hex
mock_agents = Mock()
mock_agents.find_one.return_value = {"_id": agent_id, "user": "user1"}
with patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={"agent_id": str(agent_id), "folder_id": None},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = MoveAgentToFolder().post()
assert response.status_code == 200
call_args = mock_agents.update_one.call_args
assert "$unset" in call_args[0][1]
def test_returns_404_agent_not_found(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
mock_agents = Mock()
mock_agents.find_one.return_value = None
with patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={"agent_id": str(uuid.uuid4().hex)},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = MoveAgentToFolder().post()
assert response.status_code == 404
def test_returns_400_missing_agent_id(self, app):
from application.api.user.agents.folders import MoveAgentToFolder
with app.test_request_context(
"/api/agents/folders/move_agent",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = MoveAgentToFolder().post()
assert response.status_code == 400
@pytest.mark.unit
class TestBulkMoveAgents:
def test_bulk_moves_to_folder(self, app):
from application.api.user.agents.folders import BulkMoveAgents
folder_id = uuid.uuid4().hex
agent_ids = [str(uuid.uuid4().hex), str(uuid.uuid4().hex)]
mock_agents = Mock()
mock_folders = Mock()
mock_folders.find_one.return_value = {"_id": folder_id}
with patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
), patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
):
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={"agent_ids": agent_ids, "folder_id": str(folder_id)},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = BulkMoveAgents().post()
assert response.status_code == 200
mock_agents.update_many.assert_called_once()
def test_bulk_removes_from_folders(self, app):
from application.api.user.agents.folders import BulkMoveAgents
agent_ids = [str(uuid.uuid4().hex)]
mock_agents = Mock()
with patch(
"application.api.user.agents.folders.agents_collection",
mock_agents,
):
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={"agent_ids": agent_ids},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = BulkMoveAgents().post()
assert response.status_code == 200
call_args = mock_agents.update_many.call_args
assert "$unset" in call_args[0][1]
def test_returns_400_missing_agent_ids(self, app):
from application.api.user.agents.folders import BulkMoveAgents
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = BulkMoveAgents().post()
assert response.status_code == 400
def test_returns_404_folder_not_found(self, app):
from application.api.user.agents.folders import BulkMoveAgents
mock_folders = Mock()
mock_folders.find_one.return_value = None
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
):
with app.test_request_context(
"/api/agents/folders/bulk_move",
method="POST",
json={
"agent_ids": [str(uuid.uuid4().hex)],
"folder_id": str(uuid.uuid4().hex),
},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = BulkMoveAgents().post()
assert response.status_code == 404
# =====================================================================
# Coverage gap tests (lines 64, 90-91, 100, 125-126, 132, 136)
# =====================================================================
@pytest.mark.unit
class TestAgentFoldersGaps:
def test_create_folder_no_auth(self, app):
"""Cover line 64: post returns 401 when no decoded_token."""
from application.api.user.agents.folders import AgentFolders
with app.test_request_context(
"/api/agents/folders/",
method="POST",
json={"name": "Test"},
):
from flask import request
request.decoded_token = None
response = AgentFolders().post()
assert response.status_code == 401
def test_create_folder_exception(self, app):
"""Cover lines 90-91: exception during insert_one returns 400."""
from application.api.user.agents.folders import AgentFolders
mock_folders = Mock()
mock_folders.find_one.return_value = None
mock_folders.insert_one.side_effect = Exception("db error")
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
):
with app.test_request_context(
"/api/agents/folders/",
method="POST",
json={"name": "Test"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolders().post()
assert response.status_code == 400
def test_get_folder_no_auth(self, app):
"""Cover line 100: get specific folder returns 401 when no auth."""
from application.api.user.agents.folders import AgentFolder
with app.test_request_context(
"/api/agents/folders/abc",
method="GET",
):
from flask import request
request.decoded_token = None
response = AgentFolder().get("abc")
assert response.status_code == 401
def test_get_folder_exception(self, app):
"""Cover lines 125-126: exception during find returns 400."""
from application.api.user.agents.folders import AgentFolder
mock_folders = Mock()
mock_folders.find_one.side_effect = Exception("db error")
with patch(
"application.api.user.agents.folders.agent_folders_collection",
mock_folders,
):
with app.test_request_context(
"/api/agents/folders/" + str(uuid.uuid4().hex),
method="GET",
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().get(str(uuid.uuid4().hex))
assert response.status_code == 400
def test_update_folder_no_auth(self, app):
"""Cover line 132: put returns 401 when no decoded_token."""
from application.api.user.agents.folders import AgentFolder
with app.test_request_context(
"/api/agents/folders/abc",
method="PUT",
json={"name": "Updated"},
):
from flask import request
request.decoded_token = None
response = AgentFolder().put("abc")
assert response.status_code == 401
def test_update_folder_no_data(self, app):
"""Cover line 136: put with no data returns 400."""
from application.api.user.agents.folders import AgentFolder
with app.test_request_context(
"/api/agents/folders/abc",
method="PUT",
content_type="application/json",
data="null",
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = AgentFolder().put("abc")
assert response.status_code == 400
@@ -0,0 +1,597 @@
"""Unit-level behavior of the per-Celery-task idempotency wrapper."""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import text
@contextmanager
def _patch_decorator_db(conn):
"""Route the wrapper's ``db_session`` / ``db_readonly`` at ``conn``."""
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.idempotency.db_session", _yield
), patch(
"application.api.user.idempotency.db_readonly", _yield
):
yield
def _fake_celery_self(task_id="task-123"):
"""Minimal stand-in mirroring ``self.request.id`` on a Celery task."""
self_ = MagicMock(name="celery_self")
self_.request.id = task_id
return self_
def _row_for(conn, key):
return conn.execute(
text(
"SELECT task_name, task_id, status, result_json "
"FROM task_dedup WHERE idempotency_key = :k"
),
{"k": key},
).fetchone()
@pytest.mark.unit
class TestNoKey:
def test_pass_through_no_db_hit(self, pg_conn):
from application.api.user.idempotency import with_idempotency
calls = []
@with_idempotency(task_name="thing")
def task(self, x, idempotency_key=None):
calls.append(x)
return {"x": x}
with patch(
"application.api.user.idempotency.db_session"
) as mock_session, patch(
"application.api.user.idempotency.db_readonly"
) as mock_readonly:
result = task(_fake_celery_self(), 7)
assert result == {"x": 7}
assert calls == [7]
assert mock_session.call_count == 0
assert mock_readonly.call_count == 0
def test_empty_string_key_treated_as_absent(self, pg_conn):
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
return {"ran": True}
with _patch_decorator_db(pg_conn):
result = task(_fake_celery_self(), idempotency_key="")
assert result == {"ran": True}
count = pg_conn.execute(
text("SELECT count(*) FROM task_dedup")
).scalar()
assert count == 0
@pytest.mark.unit
class TestFirstRunWithKey:
def test_records_completed_row(self, pg_conn):
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
return {"answer": 42}
with _patch_decorator_db(pg_conn):
result = task(_fake_celery_self("tid-1"), idempotency_key="k-first")
assert result == {"answer": 42}
row = _row_for(pg_conn, "k-first")
assert row is not None
assert row[0] == "thing"
assert row[1] == "tid-1"
assert row[2] == "completed"
assert row[3] == {"answer": 42}
@pytest.mark.unit
class TestSecondRunCompletedShortCircuits:
def test_returns_cached_without_invoking(self, pg_conn):
from application.api.user.idempotency import with_idempotency
invocations = {"count": 0}
@with_idempotency(task_name="thing")
def task(self, value, idempotency_key=None):
invocations["count"] += 1
return {"value": value, "n": invocations["count"]}
with _patch_decorator_db(pg_conn):
first = task(_fake_celery_self("tid-A"), 1, idempotency_key="k-rep")
second = task(_fake_celery_self("tid-B"), 2, idempotency_key="k-rep")
assert first == {"value": 1, "n": 1}
assert second == first
assert invocations["count"] == 1
@pytest.mark.unit
class TestFirstRunFails:
def test_propagates_and_leaves_pending(self, pg_conn):
"""An exception propagates so Celery's retry policy fires; the row
stays in ``pending`` (with bumped attempt_count) so the next
attempt isn't gated as already-completed.
"""
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
raise RuntimeError("kaboom")
with _patch_decorator_db(pg_conn), pytest.raises(RuntimeError, match="kaboom"):
task(_fake_celery_self("tid-X"), idempotency_key="k-fail")
row = _row_for(pg_conn, "k-fail")
assert row is not None
assert row[0] == "thing"
assert row[2] == "pending"
assert row[3] is None
@pytest.mark.unit
class TestPoisonLoopGuard:
def test_refuses_after_max_attempts(self, pg_conn):
from application.api.user.idempotency import (
MAX_TASK_ATTEMPTS, with_idempotency,
)
invocations = {"count": 0}
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
invocations["count"] += 1
raise RuntimeError("never converges")
with _patch_decorator_db(pg_conn):
for _ in range(MAX_TASK_ATTEMPTS):
with pytest.raises(RuntimeError):
task(_fake_celery_self(), idempotency_key="k-poison")
# The next entry trips the guard and *does not* call fn.
result = task(_fake_celery_self(), idempotency_key="k-poison")
assert invocations["count"] == MAX_TASK_ATTEMPTS
assert result["success"] is False
assert "poison-loop" in result["error"]
row = _row_for(pg_conn, "k-poison")
assert row[2] == "failed"
@pytest.mark.unit
class TestPreviousPendingReruns:
def test_pending_row_does_not_short_circuit(self, pg_conn):
"""HTTP boundary writes ``pending``; on first arrival the wrapper still runs."""
from application.api.user.idempotency import with_idempotency
from application.storage.db.repositories.idempotency import (
IdempotencyRepository,
)
IdempotencyRepository(pg_conn).claim_task(
key="k-pending-prior", task_name="thing",
task_id="enq-task-id",
)
invocations = {"count": 0}
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
invocations["count"] += 1
return {"final": "answer"}
with _patch_decorator_db(pg_conn):
result = task(
_fake_celery_self("worker-tid"),
idempotency_key="k-pending-prior",
)
assert result == {"final": "answer"}
assert invocations["count"] == 1
row = _row_for(pg_conn, "k-pending-prior")
assert row[2] == "completed"
assert row[3] == {"final": "answer"}
# The HTTP-claimed task_id is preserved across worker bumps so
# losers still see the same predetermined id.
assert row[1] == "enq-task-id"
@pytest.mark.unit
class TestRaceWithCompletedRow:
"""A second worker finishing after the first should not clobber the completed row."""
def test_second_record_no_ops_on_completed(self, pg_conn):
from application.api.user.idempotency import with_idempotency
from application.storage.db.repositories.idempotency import (
IdempotencyRepository,
)
# Seed a completed row directly via SQL (bypasses claim_task's
# ON CONFLICT DO NOTHING guard so this row exists for the test).
from sqlalchemy import text
pg_conn.execute(
text(
"INSERT INTO task_dedup (idempotency_key, task_name, task_id, "
"result_json, status) VALUES (:k, :tn, :tid, "
"CAST(:rj AS jsonb), 'completed')"
),
{
"k": "k-race", "tn": "thing", "tid": "winner",
"rj": '{"who": "winner"}',
},
)
# IdempotencyRepository present but unused — keep as smoke check
# the constructor still works.
IdempotencyRepository(pg_conn)
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
return {"who": "loser"}
with _patch_decorator_db(pg_conn):
result = task(_fake_celery_self("loser-tid"), idempotency_key="k-race")
assert result == {"who": "winner"}
row = _row_for(pg_conn, "k-race")
assert row[1] == "winner"
assert row[3] == {"who": "winner"}
@pytest.mark.unit
class TestLiveLeaseDefersConcurrentRun:
"""Two-worker concurrency: Worker 1 owns a fresh lease, Worker 2's
redelivery must ``self.retry`` instead of running the task body.
"""
def test_second_worker_reraises_retry_without_running(self, pg_conn):
from application.api.user.idempotency import (
LEASE_TTL_SECONDS, with_idempotency,
)
from application.storage.db.repositories.idempotency import (
IdempotencyRepository,
)
# Worker 1 has already claimed a fresh lease.
IdempotencyRepository(pg_conn).try_claim_lease(
key="k-busy", task_name="thing",
task_id="t-worker-1", owner_id="worker-1",
)
invocations = {"count": 0}
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
invocations["count"] += 1
return {"ran": True}
# ``self.retry`` raises celery.exceptions.Retry; mirror that
# contract here so the wrapper's ``raise self.retry(...)``
# propagates a real exception.
class _RetrySignal(Exception):
pass
worker2 = _fake_celery_self("t-worker-2")
worker2.retry.side_effect = _RetrySignal("retry scheduled")
with _patch_decorator_db(pg_conn), pytest.raises(_RetrySignal):
task(worker2, idempotency_key="k-busy")
# Task body never executed.
assert invocations["count"] == 0
# ``self.retry`` was invoked with countdown == lease TTL.
worker2.retry.assert_called_once()
kwargs = worker2.retry.call_args.kwargs
assert kwargs.get("countdown") == LEASE_TTL_SECONDS
def test_expired_lease_can_be_reclaimed_by_next_worker(self, pg_conn):
"""After the lease TTL elapses, the next attempt claims and runs
— the original "Worker 1 died" recovery path.
"""
from sqlalchemy import text
from application.api.user.idempotency import with_idempotency
from application.storage.db.repositories.idempotency import (
IdempotencyRepository,
)
IdempotencyRepository(pg_conn).try_claim_lease(
key="k-stale", task_name="thing",
task_id="t-1", owner_id="dead-worker",
)
# Force the lease into the past.
pg_conn.execute(
text(
"UPDATE task_dedup "
"SET lease_expires_at = clock_timestamp() "
" - make_interval(secs => 5) "
"WHERE idempotency_key = :k"
),
{"k": "k-stale"},
)
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
return {"ran": True}
with _patch_decorator_db(pg_conn):
result = task(_fake_celery_self("t-2"), idempotency_key="k-stale")
assert result == {"ran": True}
row = _row_for(pg_conn, "k-stale")
assert row[2] == "completed"
@pytest.mark.unit
class TestExceptionPathReleasesLease:
"""When ``fn`` raises, the lease is dropped so the next attempt
doesn't have to wait the full TTL before re-claiming.
"""
def test_release_clears_lease_owner(self, pg_conn):
from sqlalchemy import text
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
raise RuntimeError("boom")
with _patch_decorator_db(pg_conn), pytest.raises(RuntimeError):
task(_fake_celery_self("tid-1"), idempotency_key="k-released")
row = pg_conn.execute(
text(
"SELECT status, lease_owner_id, lease_expires_at "
"FROM task_dedup WHERE idempotency_key = :k"
),
{"k": "k-released"},
).fetchone()
assert row[0] == "pending"
assert row[1] is None
assert row[2] is None
def test_next_attempt_can_reclaim_after_release(self, pg_conn):
"""Sequential retries don't get blocked by the lease TTL."""
from application.api.user.idempotency import with_idempotency
invocations = {"count": 0}
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
invocations["count"] += 1
if invocations["count"] < 3:
raise RuntimeError("transient")
return {"finally": True}
with _patch_decorator_db(pg_conn):
for _ in range(2):
with pytest.raises(RuntimeError):
task(_fake_celery_self(), idempotency_key="k-converge")
result = task(_fake_celery_self(), idempotency_key="k-converge")
assert invocations["count"] == 3
assert result == {"finally": True}
row = _row_for(pg_conn, "k-converge")
assert row[2] == "completed"
@pytest.mark.unit
class TestSuccessfulRunClearsLease:
"""``finalize_task`` clears the lease columns so operator dashboards
don't show stale ``lease_expires_at`` on completed rows.
"""
def test_completed_row_has_null_lease(self, pg_conn):
from sqlalchemy import text
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="thing")
def task(self, idempotency_key=None):
return {"ok": True}
with _patch_decorator_db(pg_conn):
task(_fake_celery_self("tid-1"), idempotency_key="k-done")
row = pg_conn.execute(
text(
"SELECT status, lease_owner_id, lease_expires_at "
"FROM task_dedup WHERE idempotency_key = :k"
),
{"k": "k-done"},
).fetchone()
assert row[0] == "completed"
assert row[1] is None
assert row[2] is None
@pytest.mark.unit
class TestSynthesizedKeyGuardsKeylessDispatch:
"""A keyless dispatch carrying ``source_id`` is still poison-guarded:
the wrapper synthesizes a deterministic key from ``source_id``.
"""
def test_keyless_with_source_id_records_dedup_row(self, pg_conn):
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="ingest")
def task(self, idempotency_key=None, source_id=None):
return {"ran": True}
with _patch_decorator_db(pg_conn):
result = task(_fake_celery_self(), source_id="src-abc")
assert result == {"ran": True}
row = _row_for(pg_conn, "auto:ingest:src-abc")
assert row is not None
assert row[0] == "ingest"
assert row[2] == "completed"
def test_synthesized_key_stable_across_redeliveries(self, pg_conn):
"""Same ``source_id`` → same key → a redelivery short-circuits to
the cached result instead of re-running the body.
"""
from application.api.user.idempotency import with_idempotency
runs = {"count": 0}
@with_idempotency(task_name="ingest")
def task(self, idempotency_key=None, source_id=None):
runs["count"] += 1
return {"n": runs["count"]}
with _patch_decorator_db(pg_conn):
first = task(_fake_celery_self(), source_id="src-1")
second = task(_fake_celery_self(), source_id="src-1")
assert first == second == {"n": 1}
assert runs["count"] == 1
def test_poison_guard_trips_for_keyless_dispatch(self, pg_conn):
"""The core fix: a keyless OOM-looping dispatch is bounded — the
guard trips after MAX_TASK_ATTEMPTS with no explicit key.
"""
from application.api.user.idempotency import (
MAX_TASK_ATTEMPTS, with_idempotency,
)
runs = {"count": 0}
@with_idempotency(task_name="ingest")
def task(self, idempotency_key=None, source_id=None):
runs["count"] += 1
raise RuntimeError("OOM-style failure")
with _patch_decorator_db(pg_conn):
for _ in range(MAX_TASK_ATTEMPTS):
with pytest.raises(RuntimeError):
task(_fake_celery_self(), source_id="src-poison")
result = task(_fake_celery_self(), source_id="src-poison")
assert runs["count"] == MAX_TASK_ATTEMPTS
assert result["success"] is False
assert "poison-loop" in result["error"]
assert _row_for(pg_conn, "auto:ingest:src-poison")[2] == "failed"
def test_no_source_id_no_key_runs_unguarded(self, pg_conn):
"""No explicit key and no ``source_id`` anchor → pass through with
no DB writes, exactly as before.
"""
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="store_attachment")
def task(self, idempotency_key=None):
return {"ran": True}
with patch(
"application.api.user.idempotency.db_session"
) as mock_session, patch(
"application.api.user.idempotency.db_readonly"
) as mock_readonly:
result = task(_fake_celery_self())
assert result == {"ran": True}
assert mock_session.call_count == 0
assert mock_readonly.call_count == 0
def test_explicit_key_takes_precedence_over_source_id(self, pg_conn):
"""An explicit key wins; the synthesized ``auto:`` key is unused."""
from application.api.user.idempotency import with_idempotency
@with_idempotency(task_name="ingest")
def task(self, idempotency_key=None, source_id=None):
return {"ran": True}
with _patch_decorator_db(pg_conn):
task(
_fake_celery_self(),
idempotency_key="explicit-k",
source_id="src-x",
)
assert _row_for(pg_conn, "explicit-k") is not None
assert _row_for(pg_conn, "auto:ingest:src-x") is None
@pytest.mark.unit
class TestPoisonHook:
"""``on_poison`` fires on the poison-guard branch with the task's
bound arguments, and never on the success path.
"""
def test_hook_invoked_with_bound_args_on_poison(self, pg_conn):
from application.api.user.idempotency import (
MAX_TASK_ATTEMPTS, with_idempotency,
)
captured = []
def _hook(task_name, bound):
captured.append((task_name, bound))
@with_idempotency(task_name="ingest", on_poison=_hook)
def task(self, idempotency_key=None, source_id=None):
raise RuntimeError("never converges")
with _patch_decorator_db(pg_conn):
for _ in range(MAX_TASK_ATTEMPTS):
with pytest.raises(RuntimeError):
task(_fake_celery_self(), source_id="src-h")
task(_fake_celery_self(), source_id="src-h")
assert len(captured) == 1
task_name, bound = captured[0]
assert task_name == "ingest"
assert bound["source_id"] == "src-h"
def test_hook_not_invoked_on_success(self, pg_conn):
from application.api.user.idempotency import with_idempotency
calls = []
@with_idempotency(
task_name="ingest", on_poison=lambda *a: calls.append(a)
)
def task(self, idempotency_key=None, source_id=None):
return {"ok": True}
with _patch_decorator_db(pg_conn):
task(_fake_celery_self(), source_id="src-ok")
assert calls == []
def test_hook_failure_does_not_break_poison_return(self, pg_conn):
"""A throwing hook must not change the poison-guard outcome."""
from application.api.user.idempotency import (
MAX_TASK_ATTEMPTS, with_idempotency,
)
def _bad_hook(task_name, bound):
raise ValueError("hook blew up")
@with_idempotency(task_name="ingest", on_poison=_bad_hook)
def task(self, idempotency_key=None, source_id=None):
raise RuntimeError("never converges")
with _patch_decorator_db(pg_conn):
for _ in range(MAX_TASK_ATTEMPTS):
with pytest.raises(RuntimeError):
task(_fake_celery_self(), source_id="src-bad")
result = task(_fake_celery_self(), source_id="src-bad")
assert result["success"] is False
assert "poison-loop" in result["error"]
+70
View File
@@ -0,0 +1,70 @@
from unittest.mock import Mock, patch
import pytest
from flask import Flask
@pytest.fixture
def app():
app = Flask(__name__)
return app
@pytest.mark.unit
class TestModelsListResource:
def test_returns_models(self, app):
from application.api.user.models.routes import ModelsListResource
mock_model = Mock()
mock_model.to_dict.return_value = {
"id": "gpt-4",
"name": "GPT-4",
"provider": "openai",
}
mock_registry = Mock()
mock_registry.get_enabled_models.return_value = [mock_model]
mock_registry.default_model_id = "gpt-4"
with patch(
"application.api.user.models.routes.ModelRegistry.get_instance",
return_value=mock_registry,
):
with app.test_request_context("/api/models"):
response = ModelsListResource().get()
assert response.status_code == 200
assert response.json["count"] == 1
assert response.json["default_model_id"] == "gpt-4"
assert response.json["models"][0]["id"] == "gpt-4"
def test_returns_empty_models(self, app):
from application.api.user.models.routes import ModelsListResource
mock_registry = Mock()
mock_registry.get_enabled_models.return_value = []
mock_registry.default_model_id = None
with patch(
"application.api.user.models.routes.ModelRegistry.get_instance",
return_value=mock_registry,
):
with app.test_request_context("/api/models"):
response = ModelsListResource().get()
assert response.status_code == 200
assert response.json["count"] == 0
assert response.json["models"] == []
def test_returns_500_on_error(self, app):
from application.api.user.models.routes import ModelsListResource
with patch(
"application.api.user.models.routes.ModelRegistry.get_instance",
side_effect=Exception("Registry error"),
):
with app.test_request_context("/api/models"):
response = ModelsListResource().get()
assert response.status_code == 500
+563
View File
@@ -0,0 +1,563 @@
import uuid
from contextlib import contextmanager
from unittest.mock import mock_open, patch
import pytest
from flask import Flask
@pytest.fixture
def app():
app = Flask(__name__)
return app
@contextmanager
def _patch_db(conn):
"""Patch both db_session and db_readonly to yield the given conn."""
@contextmanager
def _yield_conn():
yield conn
with patch(
"application.api.user.prompts.routes.db_session", _yield_conn
), patch(
"application.api.user.prompts.routes.db_readonly", _yield_conn
):
yield
@pytest.mark.unit
class TestCreatePrompt:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.prompts.routes import CreatePrompt
with app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "P", "content": "C"},
):
from flask import request
request.decoded_token = None
response = CreatePrompt().post()
assert response.status_code == 401
def test_returns_400_missing_fields(self, app):
from application.api.user.prompts.routes import CreatePrompt
with app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "P"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = CreatePrompt().post()
assert response.status_code == 400
@pytest.mark.unit
class TestGetPrompts:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.prompts.routes import GetPrompts
with app.test_request_context("/api/get_prompts"):
from flask import request
request.decoded_token = None
response = GetPrompts().get()
assert response.status_code == 401
@pytest.mark.unit
class TestGetSinglePrompt:
pass
def test_returns_default_prompt(self, app):
from application.api.user.prompts.routes import GetSinglePrompt
with patch("builtins.open", mock_open(read_data="Default prompt content")):
with app.test_request_context("/api/get_single_prompt?id=default"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = GetSinglePrompt().get()
assert response.status_code == 200
assert response.json["content"] == "Default prompt content"
def test_returns_creative_prompt(self, app):
from application.api.user.prompts.routes import GetSinglePrompt
with patch("builtins.open", mock_open(read_data="Creative content")):
with app.test_request_context("/api/get_single_prompt?id=creative"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = GetSinglePrompt().get()
assert response.status_code == 200
assert response.json["content"] == "Creative content"
def test_returns_strict_prompt(self, app):
from application.api.user.prompts.routes import GetSinglePrompt
with patch("builtins.open", mock_open(read_data="Strict content")):
with app.test_request_context("/api/get_single_prompt?id=strict"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = GetSinglePrompt().get()
assert response.status_code == 200
assert response.json["content"] == "Strict content"
def test_returns_400_missing_id(self, app):
from application.api.user.prompts.routes import GetSinglePrompt
with app.test_request_context("/api/get_single_prompt"):
from flask import request
request.decoded_token = {"sub": "user1"}
response = GetSinglePrompt().get()
assert response.status_code == 400
@pytest.mark.unit
class TestDeletePrompt:
pass
def test_returns_400_missing_id(self, app):
from application.api.user.prompts.routes import DeletePrompt
with app.test_request_context(
"/api/delete_prompt",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = DeletePrompt().post()
assert response.status_code == 400
@pytest.mark.unit
class TestUpdatePrompt:
pass
def test_returns_400_missing_fields(self, app):
from application.api.user.prompts.routes import UpdatePrompt
with app.test_request_context(
"/api/update_prompt",
method="POST",
json={"id": str(uuid.uuid4().hex[:24]), "name": "Updated"},
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = UpdatePrompt().post()
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Happy-path tests using the ephemeral pg_conn fixture
# ---------------------------------------------------------------------------
class TestCreatePromptHappyPath:
def test_creates_prompt_returns_id(self, app, pg_conn):
from application.api.user.prompts.routes import CreatePrompt
with _patch_db(pg_conn), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "P1", "content": "c1"},
):
from flask import request
request.decoded_token = {"sub": "user-create"}
response = CreatePrompt().post()
assert response.status_code == 200
assert "id" in response.json
def test_create_error_returns_400(self, app, pg_conn):
from application.api.user.prompts.routes import CreatePrompt
# Force repository error by closing the connection first
@contextmanager
def _broken():
raise RuntimeError("simulated db error")
yield # unreachable
with patch(
"application.api.user.prompts.routes.db_session", _broken
), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "P", "content": "c"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = CreatePrompt().post()
assert response.status_code == 400
class TestGetPromptsHappyPath:
def test_returns_builtin_plus_user_prompts(self, app, pg_conn):
from application.api.user.prompts.routes import CreatePrompt, GetPrompts
user = "user-list"
# Seed two prompts via the same endpoint
for name in ("alpha", "beta"):
with _patch_db(pg_conn), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": name, "content": f"content-{name}"},
):
from flask import request
request.decoded_token = {"sub": user}
CreatePrompt().post()
with _patch_db(pg_conn), app.test_request_context("/api/get_prompts"):
from flask import request
request.decoded_token = {"sub": user}
response = GetPrompts().get()
assert response.status_code == 200
names = [p["name"] for p in response.json]
# Three built-ins always present
assert "default" in names and "creative" in names and "strict" in names
assert "alpha" in names and "beta" in names
def test_get_error_returns_400(self, app):
from application.api.user.prompts.routes import GetPrompts
@contextmanager
def _broken():
raise RuntimeError("simulated db error")
yield
with patch(
"application.api.user.prompts.routes.db_readonly", _broken
), app.test_request_context("/api/get_prompts"):
from flask import request
request.decoded_token = {"sub": "u1"}
response = GetPrompts().get()
assert response.status_code == 400
class TestGetSinglePromptHappyPath:
def test_returns_private_prompt_content(self, app, pg_conn):
from application.api.user.prompts.routes import (
CreatePrompt,
GetSinglePrompt,
)
user = "user-get1"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "custom", "content": "hello world"},
):
from flask import request
request.decoded_token = {"sub": user}
created = CreatePrompt().post()
prompt_id = created.json["id"]
with _patch_db(pg_conn), app.test_request_context(
f"/api/get_single_prompt?id={prompt_id}"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetSinglePrompt().get()
assert response.status_code == 200
assert response.json["content"] == "hello world"
def test_returns_404_for_unknown_prompt(self, app, pg_conn):
from application.api.user.prompts.routes import GetSinglePrompt
bogus_id = str(uuid.uuid4())
with _patch_db(pg_conn), app.test_request_context(
f"/api/get_single_prompt?id={bogus_id}"
):
from flask import request
request.decoded_token = {"sub": "whoever"}
response = GetSinglePrompt().get()
assert response.status_code == 404
def test_file_read_exception_returns_400(self, app):
from application.api.user.prompts.routes import GetSinglePrompt
with patch("builtins.open", side_effect=OSError("boom")), \
app.test_request_context("/api/get_single_prompt?id=default"):
from flask import request
request.decoded_token = {"sub": "u1"}
response = GetSinglePrompt().get()
assert response.status_code == 400
class TestDeletePromptHappyPath:
def test_deletes_existing_prompt(self, app, pg_conn):
from application.api.user.prompts.routes import (
CreatePrompt,
DeletePrompt,
GetSinglePrompt,
)
user = "user-del"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "to-delete", "content": "bye"},
):
from flask import request
request.decoded_token = {"sub": user}
created = CreatePrompt().post()
prompt_id = created.json["id"]
with _patch_db(pg_conn), app.test_request_context(
"/api/delete_prompt",
method="POST",
json={"id": prompt_id},
):
from flask import request
request.decoded_token = {"sub": user}
response = DeletePrompt().post()
assert response.status_code == 200
assert response.json["success"] is True
# Verify gone
with _patch_db(pg_conn), app.test_request_context(
f"/api/get_single_prompt?id={prompt_id}"
):
from flask import request
request.decoded_token = {"sub": user}
check = GetSinglePrompt().get()
assert check.status_code == 404
def test_delete_returns_401_unauthenticated(self, app):
from application.api.user.prompts.routes import DeletePrompt
with app.test_request_context(
"/api/delete_prompt",
method="POST",
json={"id": "something"},
):
from flask import request
request.decoded_token = None
response = DeletePrompt().post()
assert response.status_code == 401
def test_delete_error_returns_400(self, app):
from application.api.user.prompts.routes import DeletePrompt
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.prompts.routes.db_session", _broken
), app.test_request_context(
"/api/delete_prompt",
method="POST",
json={"id": "pid"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = DeletePrompt().post()
assert response.status_code == 400
class TestLegacyMongoIdResolution:
"""Pre-cutover prompt ids (Mongo ObjectIds) must resolve on all three
mutating endpoints once the prompts.legacy_mongo_id column is populated
by backfill. Previously the route short-circuited on non-UUID input via
``CAST(:id AS uuid)`` which raised and poisoned the transaction."""
LEGACY_ID = "507f1f77bcf86cd799439011"
def _seed_legacy(self, pg_conn, user: str, name: str, content: str):
from application.storage.db.repositories.prompts import PromptsRepository
return PromptsRepository(pg_conn).create(
user, name, content, legacy_mongo_id=self.LEGACY_ID,
)
def test_get_single_prompt_resolves_legacy_id(self, app, pg_conn):
from application.api.user.prompts.routes import GetSinglePrompt
user = "legacy-user"
self._seed_legacy(pg_conn, user, "orig", "legacy-body")
with _patch_db(pg_conn), app.test_request_context(
f"/api/get_single_prompt?id={self.LEGACY_ID}"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetSinglePrompt().get()
assert response.status_code == 200
assert response.json["content"] == "legacy-body"
def test_delete_prompt_resolves_legacy_id(self, app, pg_conn):
from application.api.user.prompts.routes import DeletePrompt
from application.storage.db.repositories.prompts import PromptsRepository
user = "legacy-user-del"
self._seed_legacy(pg_conn, user, "to-delete", "x")
with _patch_db(pg_conn), app.test_request_context(
"/api/delete_prompt",
method="POST",
json={"id": self.LEGACY_ID},
):
from flask import request
request.decoded_token = {"sub": user}
response = DeletePrompt().post()
assert response.status_code == 200
assert response.json["success"] is True
assert PromptsRepository(pg_conn).get_by_legacy_id(
self.LEGACY_ID, user,
) is None
def test_update_prompt_resolves_legacy_id(self, app, pg_conn):
from application.api.user.prompts.routes import UpdatePrompt
from application.storage.db.repositories.prompts import PromptsRepository
user = "legacy-user-upd"
self._seed_legacy(pg_conn, user, "old-name", "old-content")
with _patch_db(pg_conn), app.test_request_context(
"/api/update_prompt",
method="POST",
json={"id": self.LEGACY_ID, "name": "new-name", "content": "new-content"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdatePrompt().post()
assert response.status_code == 200
fetched = PromptsRepository(pg_conn).get_by_legacy_id(self.LEGACY_ID, user)
assert fetched["name"] == "new-name"
assert fetched["content"] == "new-content"
class TestUpdatePromptHappyPath:
def test_updates_prompt(self, app, pg_conn):
from application.api.user.prompts.routes import (
CreatePrompt,
GetSinglePrompt,
UpdatePrompt,
)
user = "user-upd"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_prompt",
method="POST",
json={"name": "orig", "content": "v1"},
):
from flask import request
request.decoded_token = {"sub": user}
created = CreatePrompt().post()
prompt_id = created.json["id"]
with _patch_db(pg_conn), app.test_request_context(
"/api/update_prompt",
method="POST",
json={"id": prompt_id, "name": "renamed", "content": "v2"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdatePrompt().post()
assert response.status_code == 200
with _patch_db(pg_conn), app.test_request_context(
f"/api/get_single_prompt?id={prompt_id}"
):
from flask import request
request.decoded_token = {"sub": user}
check = GetSinglePrompt().get()
assert check.status_code == 200
assert check.json["content"] == "v2"
def test_update_returns_401_unauthenticated(self, app):
from application.api.user.prompts.routes import UpdatePrompt
with app.test_request_context(
"/api/update_prompt",
method="POST",
json={"id": "x", "name": "n", "content": "c"},
):
from flask import request
request.decoded_token = None
response = UpdatePrompt().post()
assert response.status_code == 401
def test_update_error_returns_400(self, app):
from application.api.user.prompts.routes import UpdatePrompt
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.prompts.routes.db_session", _broken
), app.test_request_context(
"/api/update_prompt",
method="POST",
json={"id": "x", "name": "n", "content": "c"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = UpdatePrompt().post()
assert response.status_code == 400
+952
View File
@@ -0,0 +1,952 @@
"""Tests for the reconciler beat task.
Seeds stuck rows of each kind (Q1 messages, Q2 proposed tool calls, Q3
executed tool calls) and verifies that ``run_reconciliation`` either
retries (Q1 within 3 attempts) or escalates them to terminal status
with both a structured logger error and a stack_logs row.
"""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import text
# ---------------------------------------------------------------------------
# Helpers — seed rows in shapes that mirror the live writers (T3 reserve_message
# for stuck messages, T8 record_proposed for tool_call_attempts).
# ---------------------------------------------------------------------------
def _create_conv(conn, user_id: str = "u-1") -> dict:
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
return ConversationsRepository(conn).create(user_id, "rec test")
def _seed_pending_message(
conn,
*,
user_id: str = "u-1",
age_minutes: int = 6,
status: str = "pending",
) -> dict:
"""Insert a conversation_messages row in ``status`` with stale ``timestamp``."""
conv = _create_conv(conn, user_id=user_id)
row = conn.execute(
text(
"""
INSERT INTO conversation_messages (
conversation_id, position, prompt, response, status,
user_id, timestamp
)
VALUES (
CAST(:conv_id AS uuid), 0, :prompt, :resp, :status,
:user_id,
clock_timestamp() - make_interval(mins => :age)
)
RETURNING id
"""
),
{
"conv_id": conv["id"],
"prompt": "hello",
"resp": "",
"status": status,
"user_id": user_id,
"age": age_minutes,
},
).fetchone()
return {"id": str(row[0]), "conversation_id": conv["id"], "user_id": user_id}
def _seed_resuming_state(conn, conv_id: str, user_id: str, *, secs_ago: int) -> None:
"""Insert a pending_tool_state row in ``resuming`` with ``resumed_at`` backdated."""
conn.execute(
text(
"""
INSERT INTO pending_tool_state (
conversation_id, user_id, messages, pending_tool_calls,
tools_dict, tool_schemas, agent_config,
created_at, expires_at, status, resumed_at
)
VALUES (
CAST(:conv_id AS uuid), :user_id,
'[]'::jsonb, '[]'::jsonb, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb,
clock_timestamp(),
clock_timestamp() + interval '30 minutes',
'resuming',
clock_timestamp() - make_interval(secs => :secs_ago)
)
"""
),
{"conv_id": conv_id, "user_id": user_id, "secs_ago": secs_ago},
)
def _seed_pending_state(
conn, conv_id: str, user_id: str, *, expires_in_minutes: int = 30,
) -> None:
"""Insert a paused ``pending_tool_state`` row (status='pending').
A negative ``expires_in_minutes`` simulates a TTL-expired row that
the cleanup janitor hasn't yet reaped.
"""
conn.execute(
text(
"""
INSERT INTO pending_tool_state (
conversation_id, user_id, messages, pending_tool_calls,
tools_dict, tool_schemas, agent_config,
created_at, expires_at, status, resumed_at
)
VALUES (
CAST(:conv_id AS uuid), :user_id,
'[]'::jsonb, '[]'::jsonb, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb,
clock_timestamp(),
clock_timestamp() + make_interval(mins => :exp),
'pending',
NULL
)
"""
),
{"conv_id": conv_id, "user_id": user_id, "exp": expires_in_minutes},
)
def _seed_tool_call(
conn,
*,
call_id: str,
status: str,
tool_name: str = "notes",
age_minutes: int = 16,
tool_id: str | None = None,
arguments: dict | None = None,
action_name: str = "view",
) -> None:
"""Insert a tool_call_attempts row, then backdate ``attempted_at``/``updated_at``."""
conn.execute(
text(
"""
INSERT INTO tool_call_attempts (
call_id, tool_id, tool_name, action_name, arguments, status
)
VALUES (
:call_id, CAST(:tool_id AS uuid), :tool_name, :action_name,
CAST(:arguments AS jsonb), :status
)
"""
),
{
"call_id": call_id,
"tool_id": tool_id,
"tool_name": tool_name,
"action_name": action_name,
"arguments": json.dumps(arguments or {}),
"status": status,
},
)
# The set_updated_at BEFORE-UPDATE trigger would clobber updated_at;
# disable it briefly so the backdate lands.
conn.execute(text("ALTER TABLE tool_call_attempts DISABLE TRIGGER USER"))
try:
conn.execute(
text(
"""
UPDATE tool_call_attempts
SET attempted_at = clock_timestamp() - make_interval(mins => :age),
updated_at = clock_timestamp() - make_interval(mins => :age)
WHERE call_id = :call_id
"""
),
{"call_id": call_id, "age": age_minutes},
)
finally:
conn.execute(text("ALTER TABLE tool_call_attempts ENABLE TRIGGER USER"))
def _stack_logs_count(conn, query: str | None = None) -> int:
if query is None:
result = conn.execute(text("SELECT count(*) FROM stack_logs"))
else:
result = conn.execute(
text("SELECT count(*) FROM stack_logs WHERE query = :q"),
{"q": query},
)
return int(result.scalar() or 0)
@contextmanager
def _route_engine_to(pg_conn):
"""Patch ``get_engine`` so the run_reconciliation reuses the test conn."""
@contextmanager
def _fake_begin():
# The repository writes happen on this connection; rolling back
# at the end of the test cleans them up.
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"application.api.user.reconciliation.get_engine",
return_value=fake_engine,
):
yield
# ---------------------------------------------------------------------------
# Q1 — stuck messages
# ---------------------------------------------------------------------------
class TestStuckMessages:
@pytest.mark.unit
def test_first_two_attempts_increment_only(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
with _route_engine_to(pg_conn):
r1 = run_reconciliation()
r2 = run_reconciliation()
assert r1["messages_failed"] == 0
assert r2["messages_failed"] == 0
row = pg_conn.execute(
text(
"SELECT status, message_metadata "
"FROM conversation_messages WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "pending"
assert row[1]["reconcile_attempts"] == 2
@pytest.mark.unit
def test_third_attempt_marks_failed_and_emits_alert(self, pg_conn, caplog):
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
before_logs = _stack_logs_count(pg_conn, "reconciler_message_failed")
with _route_engine_to(pg_conn), caplog.at_level(
logging.ERROR, logger="application.api.user.reconciliation",
):
run_reconciliation()
run_reconciliation()
r3 = run_reconciliation()
assert r3["messages_failed"] == 1
row = pg_conn.execute(
text(
"SELECT status, message_metadata "
"FROM conversation_messages WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "failed"
assert row[1]["reconcile_attempts"] == 3
assert "reconciler:" in (row[1].get("error") or "")
# Structured alert + stack_logs row both surface the failure.
assert any(
"reconciler alert" in rec.getMessage()
and rec.levelname == "ERROR"
and getattr(rec, "alert", None) == "reconciler_message_failed"
for rec in caplog.records
)
assert (
_stack_logs_count(pg_conn, "reconciler_message_failed")
== before_logs + 1
)
@pytest.mark.unit
def test_streaming_status_also_eligible(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn, status="streaming")
with _route_engine_to(pg_conn):
run_reconciliation()
run_reconciliation()
run_reconciliation()
row = pg_conn.execute(
text(
"SELECT status FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "failed"
@pytest.mark.unit
def test_skipped_when_active_resuming_state(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
# Active resume started 60 seconds ago — within 10-min grace.
_seed_resuming_state(pg_conn, msg["conversation_id"], msg["user_id"], secs_ago=60)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["messages_failed"] == 0
row = pg_conn.execute(
text(
"SELECT status, message_metadata "
"FROM conversation_messages WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "pending"
# No attempts recorded: row was skipped, not just not-yet-failed.
assert "reconcile_attempts" not in row[1]
@pytest.mark.unit
def test_stale_resuming_does_not_skip(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
# 11 minutes ago — past the 10-minute grace window.
_seed_resuming_state(
pg_conn, msg["conversation_id"], msg["user_id"], secs_ago=11 * 60,
)
with _route_engine_to(pg_conn):
run_reconciliation()
row = pg_conn.execute(
text(
"SELECT message_metadata FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
# Stale resuming shouldn't shield: attempt counter must have moved.
assert row[0].get("reconcile_attempts") == 1
@pytest.mark.unit
def test_fresh_message_left_alone(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
# 1 minute old — well under the 5-minute threshold.
msg = _seed_pending_message(pg_conn, age_minutes=1)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["messages_failed"] == 0
row = pg_conn.execute(
text(
"SELECT status, message_metadata FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "pending"
assert "reconcile_attempts" not in row[1]
@pytest.mark.unit
def test_skipped_when_paused_pending_state_active(self, pg_conn):
"""A paused conversation (PT.status='pending') must not get failed
while the user is still considering tool approval. The PT row's
own ``expires_at`` TTL is the abandonment signal.
"""
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
_seed_pending_state(
pg_conn, msg["conversation_id"], msg["user_id"],
expires_in_minutes=30,
)
with _route_engine_to(pg_conn):
r1 = run_reconciliation()
r2 = run_reconciliation()
r3 = run_reconciliation()
assert r1["messages_failed"] == 0
assert r2["messages_failed"] == 0
assert r3["messages_failed"] == 0
row = pg_conn.execute(
text(
"SELECT status, message_metadata FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0] == "pending"
# No attempts recorded: the row was excluded from the sweep, not
# just held under the 3-attempt threshold.
assert "reconcile_attempts" not in row[1]
@pytest.mark.unit
def test_expired_pending_state_does_not_skip(self, pg_conn):
"""When the PT row's TTL has expired but the cleanup janitor
hasn't reaped it yet, the message becomes eligible immediately
— we don't wait an extra ~60s for janitor cadence to align.
"""
from application.api.user.reconciliation import run_reconciliation
msg = _seed_pending_message(pg_conn)
_seed_pending_state(
pg_conn, msg["conversation_id"], msg["user_id"],
expires_in_minutes=-1,
)
with _route_engine_to(pg_conn):
run_reconciliation()
row = pg_conn.execute(
text(
"SELECT message_metadata FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).fetchone()
assert row[0].get("reconcile_attempts") == 1
# ---------------------------------------------------------------------------
# Q2 — proposed tool calls (side effect unknown)
# ---------------------------------------------------------------------------
class TestStuckProposedToolCalls:
@pytest.mark.unit
def test_marks_proposed_failed_with_alert(self, pg_conn, caplog):
from application.api.user.reconciliation import run_reconciliation
_seed_tool_call(pg_conn, call_id="cp-1", status="proposed", age_minutes=6)
before = _stack_logs_count(pg_conn, "reconciler_tool_call_failed_proposed")
with _route_engine_to(pg_conn), caplog.at_level(
logging.ERROR, logger="application.api.user.reconciliation",
):
r = run_reconciliation()
assert r["tool_calls_failed"] == 1
row = pg_conn.execute(
text("SELECT status, error FROM tool_call_attempts WHERE call_id = :id"),
{"id": "cp-1"},
).fetchone()
assert row[0] == "failed"
assert "side effect status unknown" in (row[1] or "")
assert any(
getattr(rec, "alert", None) == "reconciler_tool_call_failed_proposed"
for rec in caplog.records
)
assert (
_stack_logs_count(pg_conn, "reconciler_tool_call_failed_proposed")
== before + 1
)
@pytest.mark.unit
def test_fresh_proposed_left_alone(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
_seed_tool_call(pg_conn, call_id="cp-2", status="proposed", age_minutes=2)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["tool_calls_failed"] == 0
row = pg_conn.execute(
text("SELECT status FROM tool_call_attempts WHERE call_id = :id"),
{"id": "cp-2"},
).fetchone()
assert row[0] == "proposed"
# ---------------------------------------------------------------------------
# Q3 — executed tool calls (escalate to failed; manual cleanup expected)
# ---------------------------------------------------------------------------
class TestStuckExecutedToolCalls:
@pytest.mark.unit
def test_executed_past_ttl_marked_failed_with_alert(self, pg_conn, caplog):
from application.api.user.reconciliation import run_reconciliation
_seed_tool_call(
pg_conn, call_id="ce-1", status="executed",
tool_name="notes", age_minutes=16,
)
before = _stack_logs_count(pg_conn, "reconciler_tool_call_failed_executed")
with _route_engine_to(pg_conn), caplog.at_level(
logging.ERROR, logger="application.api.user.reconciliation",
):
r = run_reconciliation()
assert r["tool_calls_failed"] == 1
row = pg_conn.execute(
text("SELECT status, error FROM tool_call_attempts WHERE call_id = :id"),
{"id": "ce-1"},
).fetchone()
assert row[0] == "failed"
assert "executed-not-confirmed" in (row[1] or "")
assert "manual cleanup" in (row[1] or "")
assert any(
getattr(rec, "alert", None) == "reconciler_tool_call_failed_executed"
for rec in caplog.records
)
assert (
_stack_logs_count(pg_conn, "reconciler_tool_call_failed_executed")
== before + 1
)
@pytest.mark.unit
def test_fresh_executed_left_alone(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
_seed_tool_call(
pg_conn, call_id="ce-2", status="executed",
tool_name="notes", age_minutes=5, # under 15-min threshold
)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["tool_calls_failed"] == 0
row = pg_conn.execute(
text("SELECT status FROM tool_call_attempts WHERE call_id = :id"),
{"id": "ce-2"},
).fetchone()
assert row[0] == "executed"
# ---------------------------------------------------------------------------
# Q4 — stalled ingest checkpoints (escalate to terminal 'stalled' + alert)
# ---------------------------------------------------------------------------
def _seed_ingest_progress(
conn,
*,
source_id: str,
embedded: int,
total: int,
age_minutes: int = 31,
status: str = "active",
) -> str:
"""Insert an ingest_chunk_progress row with a backdated last_updated."""
conn.execute(
text(
"""
INSERT INTO ingest_chunk_progress (
source_id, total_chunks, embedded_chunks, last_index,
last_updated, status
)
VALUES (
CAST(:sid AS uuid), :total, :embedded, :embedded - 1,
clock_timestamp() - make_interval(mins => :age),
:status
)
"""
),
{
"sid": source_id,
"total": total,
"embedded": embedded,
"age": age_minutes,
"status": status,
},
)
return source_id
def _ingest_status(conn, source_id: str) -> str | None:
"""Return the ``status`` of an ingest_chunk_progress row, or None."""
row = conn.execute(
text(
"SELECT status FROM ingest_chunk_progress "
"WHERE source_id = CAST(:sid AS uuid)"
),
{"sid": source_id},
).fetchone()
return row[0] if row is not None else None
def _seed_source(
conn, *, source_id: str, user_id: str = "u-1", name: str = "My Doc.pdf",
) -> str:
"""Insert a minimal ``sources`` row so the ingest sweep can resolve its owner."""
conn.execute(
text(
"INSERT INTO sources (id, user_id, name, type) "
"VALUES (CAST(:id AS uuid), :user_id, :name, 'file')"
),
{"id": source_id, "user_id": user_id, "name": name},
)
return source_id
def _capture_published(pg_conn):
"""Patch ``publish_user_event`` and collect ``(user, type, payload, scope)``.
Returns a ``(context_manager, captured_list)`` pair. The reconciler
imports the publisher lazily inside ``_publish_events``, so patching the
function on its home module is what intercepts the call.
"""
captured: list = []
def _fake(user_id, event_type, payload, *, scope=None):
captured.append((user_id, event_type, payload, scope))
return "1-0"
return patch(
"application.events.publisher.publish_user_event", _fake,
), captured
class TestStalledIngests:
@pytest.mark.unit
def test_stalled_ingest_escalated_with_alert(self, pg_conn, caplog):
from application.api.user.reconciliation import run_reconciliation
sid = "1a000000-0000-0000-0000-0000000000a1"
_seed_ingest_progress(pg_conn, source_id=sid, embedded=9, total=907)
before = _stack_logs_count(pg_conn, "reconciler_ingest_stalled")
with _route_engine_to(pg_conn), caplog.at_level(
logging.ERROR, logger="application.api.user.reconciliation",
):
r = run_reconciliation()
assert r["ingests_stalled"] == 1
# Escalated to a terminal status so the next tick skips it.
assert _ingest_status(pg_conn, sid) == "stalled"
# Structured alert + stack_logs row both surface the failure.
assert any(
getattr(rec, "alert", None) == "reconciler_ingest_stalled"
and rec.levelname == "ERROR"
for rec in caplog.records
)
assert (
_stack_logs_count(pg_conn, "reconciler_ingest_stalled")
== before + 1
)
@pytest.mark.unit
def test_stalled_ingest_alerts_once_not_every_tick(self, pg_conn):
"""The escalate-to-'stalled' write ends the re-alert loop: a
second tick neither re-counts nor re-logs the same dead ingest.
"""
from application.api.user.reconciliation import run_reconciliation
sid = "1a000000-0000-0000-0000-0000000000a2"
_seed_ingest_progress(pg_conn, source_id=sid, embedded=1, total=95)
before = _stack_logs_count(pg_conn, "reconciler_ingest_stalled")
with _route_engine_to(pg_conn):
r1 = run_reconciliation()
r2 = run_reconciliation()
assert r1["ingests_stalled"] == 1
assert r2["ingests_stalled"] == 0
# Only the first tick wrote an alert row.
assert (
_stack_logs_count(pg_conn, "reconciler_ingest_stalled")
== before + 1
)
@pytest.mark.unit
def test_fresh_ingest_left_alone(self, pg_conn):
from application.api.user.reconciliation import run_reconciliation
sid = "1a000000-0000-0000-0000-0000000000a3"
# 2 minutes old — well under the 30-minute staleness threshold.
_seed_ingest_progress(
pg_conn, source_id=sid, embedded=3, total=20, age_minutes=2,
)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["ingests_stalled"] == 0
assert _ingest_status(pg_conn, sid) == "active"
@pytest.mark.unit
def test_completed_ingest_left_alone(self, pg_conn):
"""A stale checkpoint that finished embedding (embedded == total)
is not a stall and must not be flagged.
"""
from application.api.user.reconciliation import run_reconciliation
sid = "1a000000-0000-0000-0000-0000000000a4"
_seed_ingest_progress(pg_conn, source_id=sid, embedded=50, total=50)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["ingests_stalled"] == 0
assert _ingest_status(pg_conn, sid) == "active"
# ---------------------------------------------------------------------------
# Q5 — stuck idempotency pending rows (lease expired + attempts exhausted)
# ---------------------------------------------------------------------------
def _seed_stuck_idempotency_row(
conn,
*,
key: str,
attempt_count: int,
lease_secs_ago: int,
) -> None:
conn.execute(
text(
"""
INSERT INTO task_dedup (
idempotency_key, task_name, task_id, status,
attempt_count, lease_owner_id, lease_expires_at,
created_at
) VALUES (
:key, 'ingest', :tid, 'pending', :attempts,
:owner,
clock_timestamp() - make_interval(secs => :secs),
clock_timestamp() - make_interval(mins => 5)
)
"""
),
{
"key": key,
"tid": f"task-{key}",
"attempts": int(attempt_count),
"owner": f"owner-{key}",
"secs": int(lease_secs_ago),
},
)
class TestStuckIdempotencyPending:
@pytest.mark.unit
def test_promotes_to_failed_with_alert(self, pg_conn, caplog):
"""A pending row whose lease expired and whose attempt counter
already hit the poison-loop threshold gets escalated to failed
so a same-key retry can re-claim instead of waiting 24 h.
"""
from application.api.user.reconciliation import run_reconciliation
_seed_stuck_idempotency_row(
pg_conn, key="abandoned", attempt_count=5, lease_secs_ago=120,
)
before = _stack_logs_count(
pg_conn, "reconciler_idempotency_pending_failed",
)
with _route_engine_to(pg_conn), caplog.at_level(
logging.ERROR, logger="application.api.user.reconciliation",
):
r = run_reconciliation()
assert r["idempotency_pending_failed"] == 1
row = pg_conn.execute(
text(
"SELECT status, result_json FROM task_dedup "
"WHERE idempotency_key = :k"
),
{"k": "abandoned"},
).fetchone()
assert row[0] == "failed"
assert row[1]["reconciled"] is True
assert "lease expired" in row[1]["error"]
assert any(
getattr(rec, "alert", None) == "reconciler_idempotency_pending_failed"
for rec in caplog.records
)
assert (
_stack_logs_count(
pg_conn, "reconciler_idempotency_pending_failed",
)
== before + 1
)
@pytest.mark.unit
def test_skips_under_attempt_threshold(self, pg_conn):
"""Attempt count below the threshold means the wrapper might
still re-claim cleanly — leave the row alone.
"""
from application.api.user.reconciliation import run_reconciliation
_seed_stuck_idempotency_row(
pg_conn, key="recoverable", attempt_count=2, lease_secs_ago=120,
)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["idempotency_pending_failed"] == 0
row = pg_conn.execute(
text("SELECT status FROM task_dedup WHERE idempotency_key = :k"),
{"k": "recoverable"},
).fetchone()
assert row[0] == "pending"
@pytest.mark.unit
def test_skips_within_lease_grace(self, pg_conn):
"""A lease that just expired (10 s ago) might be in the
heartbeat-tick window; the 60 s grace keeps the sweep quiet.
"""
from application.api.user.reconciliation import run_reconciliation
_seed_stuck_idempotency_row(
pg_conn, key="just-expired", attempt_count=5, lease_secs_ago=10,
)
with _route_engine_to(pg_conn):
r = run_reconciliation()
assert r["idempotency_pending_failed"] == 0
# ---------------------------------------------------------------------------
# Clearing / terminal user-facing events (revoke stale UI surfaces)
# ---------------------------------------------------------------------------
class TestApprovalClearedEvents:
@pytest.mark.unit
def test_message_failed_clears_pending_approval(self, pg_conn):
"""A reconciled-to-failed message deletes its resumable state and
publishes ``tool.approval.cleared`` so the approval toast doesn't
linger after reconnect.
"""
from application.api.user import reconciliation as recon
msg = _seed_pending_message(pg_conn)
# Expired PT row: doesn't shield the message (past TTL) but is the
# resumable state the failure path must delete + revoke.
_seed_pending_state(
pg_conn, msg["conversation_id"], msg["user_id"],
expires_in_minutes=-1,
)
ctx, published = _capture_published(pg_conn)
with _route_engine_to(pg_conn), ctx:
recon.run_reconciliation()
recon.run_reconciliation()
recon.run_reconciliation()
status = pg_conn.execute(
text(
"SELECT status FROM conversation_messages "
"WHERE id = CAST(:id AS uuid)"
),
{"id": msg["id"]},
).scalar()
assert status == "failed"
# Resumable state is gone.
pt_count = pg_conn.execute(
text(
"SELECT count(*) FROM pending_tool_state "
"WHERE conversation_id = CAST(:c AS uuid)"
),
{"c": msg["conversation_id"]},
).scalar()
assert pt_count == 0
cleared = [p for p in published if p[1] == "tool.approval.cleared"]
assert len(cleared) == 1
user_id, _, payload, scope = cleared[0]
assert user_id == msg["user_id"]
assert payload["conversation_id"] == msg["conversation_id"]
assert payload["message_id"] == msg["id"]
assert payload["reason"] == "failed"
assert scope == {"kind": "conversation", "id": msg["conversation_id"]}
@pytest.mark.unit
def test_message_failed_without_approval_emits_no_clear(self, pg_conn):
"""A plain stuck message (no resumable state) must not emit a
spurious clearing event.
"""
from application.api.user import reconciliation as recon
_seed_pending_message(pg_conn)
ctx, published = _capture_published(pg_conn)
with _route_engine_to(pg_conn), ctx:
recon.run_reconciliation()
recon.run_reconciliation()
recon.run_reconciliation()
assert not any(p[1] == "tool.approval.cleared" for p in published)
class TestStalledIngestEvent:
@pytest.mark.unit
def test_stalled_ingest_emits_source_failed_event(self, pg_conn):
from application.api.user import reconciliation as recon
sid = "1a000000-0000-0000-0000-0000000000b1"
_seed_source(pg_conn, source_id=sid, user_id="u-ingest", name="report.pdf")
_seed_ingest_progress(pg_conn, source_id=sid, embedded=2, total=50)
ctx, published = _capture_published(pg_conn)
with _route_engine_to(pg_conn), ctx:
r = recon.run_reconciliation()
assert r["ingests_stalled"] == 1
failed = [p for p in published if p[1] == "source.ingest.failed"]
assert len(failed) == 1
user_id, _, payload, scope = failed[0]
assert user_id == "u-ingest"
assert payload["source_id"] == sid
assert payload["filename"] == "report.pdf"
assert scope == {"kind": "source", "id": sid}
@pytest.mark.unit
def test_orphan_source_stalls_without_event(self, pg_conn):
"""An ingest row with no matching ``sources`` row (deleted source)
still escalates to 'stalled' but emits no user event.
"""
from application.api.user import reconciliation as recon
sid = "1a000000-0000-0000-0000-0000000000b2"
_seed_ingest_progress(pg_conn, source_id=sid, embedded=1, total=20)
ctx, published = _capture_published(pg_conn)
with _route_engine_to(pg_conn), ctx:
r = recon.run_reconciliation()
assert r["ingests_stalled"] == 1
assert _ingest_status(pg_conn, sid) == "stalled"
assert not any(p[1] == "source.ingest.failed" for p in published)
# ---------------------------------------------------------------------------
# Skip path
# ---------------------------------------------------------------------------
class TestPostgresUriMissing:
@pytest.mark.unit
def test_returns_skip_dict(self, monkeypatch):
from application.api.user.reconciliation import run_reconciliation
from application.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = run_reconciliation()
assert result == {
"messages_failed": 0,
"tool_calls_failed": 0,
"skipped": "POSTGRES_URI not set",
}
+401
View File
@@ -0,0 +1,401 @@
"""Tests for the scheduler dispatcher (engine-level, no Celery worker)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
from application.api.user.scheduler_dispatcher import dispatch_due_runs
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
def _now() -> datetime:
return datetime.now(timezone.utc)
def _make_agent(conn, user_id: str = "u1") -> str:
row = conn.execute(
text(
"INSERT INTO agents (user_id, name, status) "
"VALUES (:u, 'a', 'draft') RETURNING id"
),
{"u": user_id},
).fetchone()
return str(row[0])
@pytest.fixture
def patched_engine(pg_engine, monkeypatch):
monkeypatch.setattr(
"application.api.user.scheduler_dispatcher.get_engine",
lambda: pg_engine,
)
yield pg_engine
@pytest.fixture
def stub_enqueue(monkeypatch):
"""Capture every execute_scheduled_run.apply_async."""
enqueued: list[str] = []
class _Task:
@staticmethod
def apply_async(args=None, **kwargs):
if args:
enqueued.append(args[0])
monkeypatch.setattr(
"application.api.user.tasks.execute_scheduled_run", _Task
)
return enqueued
def _create_schedule(engine, **kwargs):
with engine.begin() as conn:
return SchedulesRepository(conn).create(**kwargs)
def _set_postgres_uri(monkeypatch, pg_engine):
monkeypatch.setattr(
"application.api.user.scheduler_dispatcher.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_MISFIRE_GRACE": 60,
})(),
)
class TestDispatcherBasic:
def test_due_recurring_enqueues_once_and_advances(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
_set_postgres_uri(monkeypatch, pg_engine)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() - timedelta(seconds=5),
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 1
assert len(stub_enqueue) == 1
with pg_engine.connect() as conn:
row = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["next_run_at"] is not None
def test_once_dispatch_nulls_next_run_at_keeps_active(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
"""Once: dispatcher nulls next_run_at but leaves status='active' for the worker."""
_set_postgres_uri(monkeypatch, pg_engine)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="i", run_at=_now() + timedelta(seconds=1),
next_run_at=_now() - timedelta(seconds=5),
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 1
with pg_engine.connect() as conn:
row = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "active"
assert row["next_run_at"] is None
class TestDedupConstraint:
def test_double_dispatch_only_one_run(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
_set_postgres_uri(monkeypatch, pg_engine)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="*/5 * * * *",
next_run_at=_now() - timedelta(seconds=2),
)
# Pre-claim simulates a racing dispatcher tick.
with pg_engine.begin() as conn:
row = SchedulesRepository(conn).get_internal(str(schedule["id"]))
ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]),
"u1",
str(row["agent_id"]),
row["next_run_at"],
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 0
assert stub_enqueue == []
class TestMisfireGrace:
def test_stale_tick_recorded_skipped(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
_set_postgres_uri(monkeypatch, pg_engine)
monkeypatch.setattr(
"application.api.user.scheduler_dispatcher.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_MISFIRE_GRACE": 30,
})(),
)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="*/5 * * * *",
next_run_at=_now() - timedelta(hours=2),
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 0
assert counts["skipped"] >= 1
with pg_engine.connect() as conn:
runs = ScheduleRunsRepository(conn).list_runs(
str(schedule["id"]), "u1",
)
assert any(r["error_type"] == "missed" for r in runs)
class TestOverlap:
def test_active_run_blocks_dispatch(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
_set_postgres_uri(monkeypatch, pg_engine)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="*/5 * * * *",
next_run_at=_now() - timedelta(seconds=2),
)
# Pre-create a running run with a different scheduled_for so overlap fires.
with pg_engine.begin() as conn:
row = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]),
"u1",
str(agent_id),
_now() - timedelta(minutes=10),
)
ScheduleRunsRepository(conn).mark_running(row["id"], "t1")
counts = dispatch_due_runs()
assert counts["enqueued"] == 0
def test_once_overlap_clears_next_run_at(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
"""Once + overlap nulls next_run_at so the dispatcher stops re-picking."""
_set_postgres_uri(monkeypatch, pg_engine)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="i", run_at=_now() + timedelta(seconds=30),
next_run_at=_now() - timedelta(seconds=5),
)
with pg_engine.begin() as conn:
existing = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]),
"u1",
str(agent_id),
_now() - timedelta(minutes=10),
)
ScheduleRunsRepository(conn).mark_running(existing["id"], "t-prev")
dispatch_due_runs()
with pg_engine.connect() as conn:
row = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "active"
assert row["next_run_at"] is None
class TestAgentlessSchedules:
def test_dispatcher_claims_and_enqueues_agentless_once(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
"""``agent_id IS NULL`` rows are claimed like any other once-schedule."""
_set_postgres_uri(monkeypatch, pg_engine)
schedule = _create_schedule(
pg_engine,
user_id="u-agentless", agent_id=None, trigger_type="once",
instruction="agentless ping",
run_at=_now() + timedelta(seconds=30),
next_run_at=_now() - timedelta(seconds=5),
origin_conversation_id=None,
created_via="chat",
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 1
assert len(stub_enqueue) == 1
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
run_row = conn.execute(
text(
"SELECT * FROM schedule_runs "
"WHERE schedule_id = CAST(:s AS uuid)"
),
{"s": str(schedule["id"])},
).fetchone()
# Once: dispatcher nulled next_run_at, schedule still active.
assert sched["status"] == "active"
assert sched["next_run_at"] is None
# The pending run carries NULL agent_id (matches the parent).
assert run_row._mapping["agent_id"] is None
assert run_row._mapping["user_id"] == "u-agentless"
class TestAgentlessRoundTrip:
"""Agentless chat → tool → dispatcher → headless run → message appended."""
def test_agentless_dispatch_executes_and_appends_message(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
from unittest.mock import patch
from application.api.user.scheduler_worker import (
execute_scheduled_run_body,
)
_set_postgres_uri(monkeypatch, pg_engine)
monkeypatch.setattr(
"application.api.user.scheduler_worker.get_engine",
lambda: pg_engine,
)
monkeypatch.setattr(
"application.api.user.scheduler_worker.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_AUTOPAUSE_FAILURES": 3,
})(),
)
monkeypatch.setattr(
"application.api.user.scheduler_worker.publish_user_event",
lambda *a, **k: "1-0",
)
with pg_engine.begin() as conn:
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, name) "
"VALUES ('u-e2e', 'agentless-chat') RETURNING id"
)
).fetchone()[0]
schedule = _create_schedule(
pg_engine,
user_id="u-e2e", agent_id=None, trigger_type="once",
instruction="ping later",
run_at=_now() + timedelta(seconds=30),
next_run_at=_now() - timedelta(seconds=5),
origin_conversation_id=str(conv_id),
created_via="chat",
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 1
run_id = stub_enqueue[0]
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "agentless e2e done",
"tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 1, "generated_tokens": 1,
"denied": [], "error_type": None, "model_id": "fake",
},
):
result = execute_scheduled_run_body(run_id, "celery-e2e")
assert result["status"] == "success"
with pg_engine.connect() as conn:
run = ScheduleRunsRepository(conn).get_internal(run_id)
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
messages = conn.execute(
text(
"SELECT * FROM conversation_messages "
"WHERE conversation_id = CAST(:c AS uuid)"
),
{"c": str(conv_id)},
).fetchall()
assert run["status"] == "success"
assert run["agent_id"] is None
assert sched["status"] == "completed"
assert sched["agent_id"] is None
assert len(messages) == 1
meta = messages[0]._mapping["message_metadata"]
assert meta.get("scheduled") is True
class TestOnceRoundTrip:
"""End-to-end: chat-driven once-schedule executes and the schedule completes."""
def test_once_dispatch_executes_and_completes_schedule(
self, pg_engine, patched_engine, stub_enqueue, monkeypatch,
):
from unittest.mock import patch
from application.api.user.scheduler_worker import (
execute_scheduled_run_body,
)
_set_postgres_uri(monkeypatch, pg_engine)
monkeypatch.setattr(
"application.api.user.scheduler_worker.get_engine",
lambda: pg_engine,
)
monkeypatch.setattr(
"application.api.user.scheduler_worker.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_AUTOPAUSE_FAILURES": 3,
})(),
)
monkeypatch.setattr(
"application.api.user.scheduler_worker.publish_user_event",
lambda *a, **k: "1-0",
)
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = _create_schedule(
pg_engine,
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="follow up", run_at=_now() + timedelta(seconds=1),
next_run_at=_now() - timedelta(seconds=5),
)
counts = dispatch_due_runs()
assert counts["enqueued"] == 1
assert len(stub_enqueue) == 1
run_id = stub_enqueue[0]
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert sched["status"] == "active"
assert sched["next_run_at"] is None
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "done",
"tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 1, "generated_tokens": 1,
"denied": [], "error_type": None, "model_id": "fake",
},
):
result = execute_scheduled_run_body(run_id, "celery-c1")
assert result["status"] == "success"
with pg_engine.connect() as conn:
run = ScheduleRunsRepository(conn).get_internal(run_id)
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert run["status"] == "success"
assert run["output"] == "done"
assert sched["status"] == "completed"
assert sched["next_run_at"] is None
+282
View File
@@ -0,0 +1,282 @@
"""Tests for the scheduler reconciliation sweep + cleanup task."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
from application.api.user.reconciliation import run_reconciliation
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
def _now() -> datetime:
return datetime.now(timezone.utc)
def _make_pending_run(conn, *, user_id="u1"):
agent_id = str(
conn.execute(
text(
"INSERT INTO agents (user_id, name, status) "
"VALUES (:u, 'a', 'draft') RETURNING id"
),
{"u": user_id},
).fetchone()[0]
)
schedule = SchedulesRepository(conn).create(
user_id=user_id, agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="*/5 * * * *",
next_run_at=_now() + timedelta(minutes=5),
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), user_id, agent_id, _now(),
)
return schedule, run, agent_id
def _make_once_pending_run(conn, *, user_id="u1"):
"""Once-schedule + pending run variant of _make_pending_run."""
agent_id = str(
conn.execute(
text(
"INSERT INTO agents (user_id, name, status) "
"VALUES (:u, 'a', 'draft') RETURNING id"
),
{"u": user_id},
).fetchone()[0]
)
fire = _now() + timedelta(minutes=5)
schedule = SchedulesRepository(conn).create(
user_id=user_id, agent_id=agent_id, trigger_type="once",
instruction="do once", run_at=fire,
next_run_at=fire,
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), user_id, agent_id, fire,
)
return schedule, run, agent_id
@pytest.fixture
def patched_engine(pg_engine, monkeypatch):
monkeypatch.setattr(
"application.api.user.reconciliation.get_engine",
lambda: pg_engine,
)
monkeypatch.setattr(
"application.api.user.reconciliation.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_RUN_TIMEOUT": 60,
})(),
)
yield pg_engine
class TestReconciler:
def test_stuck_running_flipped_to_timeout(self, pg_engine, patched_engine):
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
ScheduleRunsRepository(conn).mark_running(run["id"], "t1")
conn.execute(
text(
"UPDATE schedule_runs "
"SET started_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
summary = run_reconciliation()
assert summary["schedule_runs_failed"] >= 1
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(run["id"])
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "timeout"
assert row["error_type"] == "timeout"
assert sched["consecutive_failure_count"] == 1
def test_stuck_pending_flipped_to_failed(self, pg_engine, patched_engine):
"""A pending run whose worker never started reconciles to 'failed'."""
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
conn.execute(
text(
"UPDATE schedule_runs "
"SET created_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
summary = run_reconciliation()
assert summary["schedule_runs_failed"] >= 1
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(run["id"])
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "failed"
assert row["error_type"] == "internal"
assert "worker_never_started" in (row["error"] or "")
assert sched["consecutive_failure_count"] == 1
def test_once_schedule_with_stuck_running_run_marked_completed(
self, pg_engine, patched_engine,
):
"""Once + stuck 'running' run -> parent flipped to 'completed'."""
with pg_engine.begin() as conn:
schedule, run, _ = _make_once_pending_run(conn)
ScheduleRunsRepository(conn).mark_running(run["id"], "t-once")
conn.execute(
text(
"UPDATE schedule_runs "
"SET started_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
run_reconciliation()
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
row = ScheduleRunsRepository(conn).get_internal(run["id"])
assert row["status"] == "timeout"
assert sched["status"] == "completed", (
"stuck once-run must terminal-flip the parent schedule"
)
assert sched["next_run_at"] is None
def test_once_schedule_with_stuck_pending_run_marked_completed(
self, pg_engine, patched_engine,
):
"""Once + stuck 'pending' run -> parent flipped to 'completed'."""
with pg_engine.begin() as conn:
schedule, run, _ = _make_once_pending_run(conn)
conn.execute(
text(
"UPDATE schedule_runs "
"SET created_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
run_reconciliation()
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
row = ScheduleRunsRepository(conn).get_internal(run["id"])
assert row["status"] == "failed"
assert sched["status"] == "completed", (
"stuck pending once-run must terminal-flip the parent schedule"
)
assert sched["next_run_at"] is None
def test_agentless_once_stuck_running_marked_completed(
self, pg_engine, patched_engine,
):
"""Stuck-run terminal flip works for agentless once-schedules."""
with pg_engine.begin() as conn:
fire = _now() + timedelta(minutes=5)
schedule = SchedulesRepository(conn).create(
user_id="u-agentless", agent_id=None, trigger_type="once",
instruction="agentless go", run_at=fire,
next_run_at=fire,
created_via="chat",
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u-agentless", None, fire,
)
ScheduleRunsRepository(conn).mark_running(run["id"], "t-stuck")
conn.execute(
text(
"UPDATE schedule_runs "
"SET started_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
run_reconciliation()
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
row = ScheduleRunsRepository(conn).get_internal(run["id"])
assert row["status"] == "timeout"
assert sched["status"] == "completed"
assert sched["next_run_at"] is None
def test_recurring_schedule_with_stuck_run_stays_active(
self, pg_engine, patched_engine,
):
"""Recurring keeps firing; only the run flips, not the parent."""
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
ScheduleRunsRepository(conn).mark_running(run["id"], "t-rec")
conn.execute(
text(
"UPDATE schedule_runs "
"SET started_at = now() - interval '120 minutes' "
"WHERE id = CAST(:i AS uuid)"
),
{"i": run["id"]},
)
run_reconciliation()
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert sched["status"] == "active"
assert sched["consecutive_failure_count"] == 1
class TestCleanup:
def test_cleanup_schedule_runs_trims_old_rows(self, pg_engine, monkeypatch):
from application.api.user.tasks import cleanup_schedule_runs as _task
monkeypatch.setattr(
"application.storage.db.engine.get_engine",
lambda: pg_engine,
)
class S:
POSTGRES_URI = str(pg_engine.url)
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS = 30
monkeypatch.setattr("application.api.user.tasks.settings", S, raising=False)
monkeypatch.setattr(
"application.core.settings.settings.POSTGRES_URI",
str(pg_engine.url),
raising=False,
)
monkeypatch.setattr(
"application.core.settings.settings.SCHEDULE_RUN_OUTPUT_RETENTION_DAYS",
30,
raising=False,
)
with pg_engine.begin() as conn:
schedule, _, _ = _make_pending_run(conn)
for i in range(60):
conn.execute(
text(
"""
INSERT INTO schedule_runs (
schedule_id, user_id, agent_id, status,
scheduled_for, created_at
) VALUES (
CAST(:s AS uuid), 'u1',
CAST(:a AS uuid), 'success',
now() - interval '100 days' - (:i * interval '1 second'),
now() - interval '100 days'
)
"""
),
{"s": str(schedule["id"]),
"a": str(schedule["agent_id"]),
"i": i},
)
result = _task.run()
assert isinstance(result.get("deleted"), int)
assert result["deleted"] >= 1
+428
View File
@@ -0,0 +1,428 @@
"""Tests for execute_scheduled_run_body (mocked agent run)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import text
from application.api.user.scheduler_worker import execute_scheduled_run_body
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
def _now() -> datetime:
return datetime.now(timezone.utc)
def _make_agent(conn, user_id: str = "u1") -> str:
row = conn.execute(
text(
"INSERT INTO agents (user_id, name, status, default_model_id) "
"VALUES (:u, 'a', 'draft', '') RETURNING id"
),
{"u": user_id},
).fetchone()
return str(row[0])
def _make_pending_run(conn, *, user_id="u1"):
agent_id = _make_agent(conn, user_id)
schedule = SchedulesRepository(conn).create(
user_id=user_id, agent_id=agent_id, trigger_type="recurring",
instruction="hello", cron="* * * * *",
next_run_at=_now() + timedelta(minutes=5),
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]),
user_id,
agent_id,
_now(),
)
return schedule, run, agent_id
@pytest.fixture
def patched_engine(pg_engine, monkeypatch):
monkeypatch.setattr(
"application.api.user.scheduler_worker.get_engine",
lambda: pg_engine,
)
monkeypatch.setattr(
"application.api.user.scheduler_worker.settings",
type("S", (), {
"POSTGRES_URI": str(pg_engine.url),
"SCHEDULE_AUTOPAUSE_FAILURES": 2,
})(),
)
yield pg_engine
@pytest.fixture
def stub_events(monkeypatch):
captured: list[tuple] = []
def _fake_publish(user_id, event_type, payload, *, scope=None):
captured.append((event_type, payload, scope))
return "1-0"
monkeypatch.setattr(
"application.api.user.scheduler_worker.publish_user_event",
_fake_publish,
)
return captured
class TestExecuteScheduledRunBody:
def test_success_flow(self, pg_engine, patched_engine, stub_events):
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "all done",
"tool_calls": [],
"sources": [],
"thought": "",
"prompt_tokens": 10,
"generated_tokens": 5,
"denied": [],
"error_type": None,
"model_id": "fake-model",
},
):
result = execute_scheduled_run_body(str(run["id"]), "celery-1")
assert result["status"] == "success"
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "success"
assert row["output"] == "all done"
assert row["prompt_tokens"] == 10
assert sched["consecutive_failure_count"] == 0
event_types = [e[0] for e in stub_events]
assert "schedule.run.completed" in event_types
def test_agent_exception_marks_failed_and_bumps(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
side_effect=RuntimeError("boom"),
):
result = execute_scheduled_run_body(str(run["id"]), "celery-2")
assert result["status"] == "failed"
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert row["status"] == "failed"
assert row["error_type"] == "agent_error"
assert sched["consecutive_failure_count"] == 1
assert "schedule.run.failed" in {e[0] for e in stub_events}
def test_autopause_after_threshold(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
schedule, run, agent_id = _make_pending_run(conn)
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
another_run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]),
"u1",
agent_id,
_now() + timedelta(seconds=1),
)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
side_effect=RuntimeError("boom"),
):
execute_scheduled_run_body(str(another_run["id"]), "celery-3")
with pg_engine.connect() as conn:
sched = SchedulesRepository(conn).get_internal(str(schedule["id"]))
assert sched["status"] == "paused"
assert "schedule.autopaused" in {e[0] for e in stub_events}
def test_denied_with_empty_output_marks_tool_not_allowed(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
schedule, run, _ = _make_pending_run(conn)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "",
"tool_calls": [],
"sources": [],
"thought": "",
"prompt_tokens": 1,
"generated_tokens": 0,
"denied": [{"tool_name": "telegram"}],
"error_type": "tool_not_allowed",
"model_id": "fake",
},
):
execute_scheduled_run_body(str(run["id"]), "celery-4")
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
assert row["status"] == "failed"
assert row["error_type"] == "tool_not_allowed"
def test_one_time_loads_chat_history(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = SchedulesRepository(conn).create(
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="follow up", run_at=_now() + timedelta(seconds=5),
next_run_at=_now(),
)
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, agent_id, name) "
"VALUES ('u1', CAST(:a AS uuid), 'origin') RETURNING id"
),
{"a": agent_id},
).fetchone()[0]
SchedulesRepository(conn).update_internal(
str(schedule["id"]),
{"origin_conversation_id": str(conv_id)},
)
conn.execute(
text(
"""
INSERT INTO conversation_messages
(conversation_id, position, prompt, response, user_id)
VALUES (CAST(:c AS uuid), 0, 'hello', 'hi', 'u1')
"""
),
{"c": str(conv_id)},
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u1", agent_id, _now(),
)
captured: dict = {}
def _fake_run(agent_config, query, **kwargs):
captured.update(kwargs)
return {
"answer": "follow-up answer",
"tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 1, "generated_tokens": 1,
"denied": [], "error_type": None, "model_id": "fake",
}
with patch(
"application.api.user.scheduler_worker.run_agent_headless", _fake_run,
):
execute_scheduled_run_body(str(run["id"]), "celery-h")
assert len(captured.get("chat_history", [])) == 1
assert captured["chat_history"][0]["prompt"] == "hello"
def test_agentless_schedule_uses_system_defaults_and_appends(
self, pg_engine, patched_engine, stub_events,
):
"""Agentless ``once`` schedule → ephemeral classic agent → message appended."""
with pg_engine.begin() as conn:
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, name) "
"VALUES ('u1', 'agentless-origin') RETURNING id"
)
).fetchone()[0]
schedule = SchedulesRepository(conn).create(
user_id="u1", agent_id=None, trigger_type="once",
instruction="follow up agentless",
run_at=_now() + timedelta(seconds=5),
next_run_at=_now(),
origin_conversation_id=str(conv_id),
created_via="chat",
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u1", None, _now(),
)
captured: dict = {}
def _fake_run(agent_config, query, **kwargs):
captured["agent_config"] = agent_config
captured["kwargs"] = kwargs
return {
"answer": "agentless ran",
"tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 4, "generated_tokens": 6,
"denied": [], "error_type": None, "model_id": "fake",
}
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
_fake_run,
):
result = execute_scheduled_run_body(str(run["id"]), "celery-agentless")
assert result["status"] == "success"
# Ephemeral classic config: no source, default retriever, no agent id.
cfg = captured["agent_config"]
assert cfg["id"] is None
assert cfg["user_id"] == "u1"
assert cfg["agent_type"] == "classic"
assert cfg["retriever"] == "classic"
assert cfg["prompt_id"] == "default"
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
messages = conn.execute(
text(
"SELECT * FROM conversation_messages "
"WHERE conversation_id = CAST(:c AS uuid)"
),
{"c": str(conv_id)},
).fetchall()
assert row["status"] == "success"
assert row["output"] == "agentless ran"
assert row["conversation_id"] is not None
assert len(messages) == 1
# The published event payload tolerates a NULL agent_id.
appended_events = [e for e in stub_events if e[0] == "schedule.message.appended"]
assert appended_events
def test_agentless_ephemeral_config_omits_tools_snapshot(
self, pg_engine, patched_engine, stub_events,
):
"""Dead ``tools`` snapshot dropped — toolset is rebuilt at fire time."""
with pg_engine.begin() as conn:
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, name) "
"VALUES ('u1', 'no-tools-snap') RETURNING id"
)
).fetchone()[0]
schedule = SchedulesRepository(conn).create(
user_id="u1", agent_id=None, trigger_type="once",
instruction="x", run_at=_now() + timedelta(seconds=5),
next_run_at=_now(),
origin_conversation_id=str(conv_id),
created_via="chat",
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u1", None, _now(),
)
captured: dict = {}
def _fake_run(agent_config, query, **kwargs):
captured["agent_config"] = agent_config
return {
"answer": "ok", "tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 1, "generated_tokens": 1,
"denied": [], "error_type": None, "model_id": "fake",
}
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
_fake_run,
):
execute_scheduled_run_body(str(run["id"]), "celery-no-snap")
cfg = captured["agent_config"]
# ``tools`` MUST NOT be in the ephemeral shape — the runtime
# toolset is rebuilt by ``ToolExecutor`` (which honours headless
# filtering for chat-only tools like ``scheduler``).
assert "tools" not in cfg
def test_agentless_token_usage_row_has_null_agent_id(
self, pg_engine, patched_engine, stub_events,
):
"""token_usage row for an agentless run carries ``agent_id IS NULL``."""
with pg_engine.begin() as conn:
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, name) "
"VALUES ('u1', 'agentless-tu') RETURNING id"
)
).fetchone()[0]
schedule = SchedulesRepository(conn).create(
user_id="u1", agent_id=None, trigger_type="once",
instruction="tu", run_at=_now() + timedelta(seconds=5),
next_run_at=_now(),
origin_conversation_id=str(conv_id),
created_via="chat",
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u1", None, _now(),
)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "yes",
"tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 11, "generated_tokens": 7,
"denied": [], "error_type": None, "model_id": "fake",
},
):
execute_scheduled_run_body(str(run["id"]), "celery-tu")
with pg_engine.connect() as conn:
tu_row = conn.execute(
text(
"SELECT * FROM token_usage "
"WHERE request_id = :r"
),
{"r": str(run["id"])},
).fetchone()
assert tu_row is not None
assert tu_row._mapping["agent_id"] is None
assert tu_row._mapping["source"] == "schedule"
def test_one_time_appends_message(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
agent_id = _make_agent(conn)
schedule = SchedulesRepository(conn).create(
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="hello", run_at=_now() + timedelta(seconds=5),
next_run_at=_now(),
)
conv_id = conn.execute(
text(
"INSERT INTO conversations (user_id, agent_id, name) "
"VALUES ('u1', CAST(:a AS uuid), 'origin') RETURNING id"
),
{"a": agent_id},
).fetchone()[0]
SchedulesRepository(conn).update_internal(
str(schedule["id"]),
{"origin_conversation_id": str(conv_id)},
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "u1", agent_id, _now(),
)
with patch(
"application.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "scheduled answer",
"tool_calls": [],
"sources": [],
"thought": "",
"prompt_tokens": 2,
"generated_tokens": 3,
"denied": [],
"error_type": None,
"model_id": "fake",
},
):
execute_scheduled_run_body(str(run["id"]), "celery-5")
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
messages = conn.execute(
text(
"SELECT * FROM conversation_messages "
"WHERE conversation_id = CAST(:c AS uuid)"
),
{"c": str(conv_id)},
).fetchall()
assert row["conversation_id"] is not None
assert row["message_id"] is not None
assert len(messages) == 1
meta = messages[0]._mapping["message_metadata"]
assert meta.get("scheduled") is True
assert "schedule.message.appended" in {e[0] for e in stub_events}
+508
View File
@@ -0,0 +1,508 @@
"""Tests for the schedules REST API."""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from flask import Flask
from sqlalchemy import text
from application.storage.db.repositories.schedules import SchedulesRepository
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.schedules.routes.db_session", _yield,
), patch(
"application.api.user.schedules.routes.db_readonly", _yield,
):
yield
def _now() -> datetime:
return datetime.now(timezone.utc)
def _make_agent(conn, user_id: str = "u1") -> str:
row = conn.execute(
text(
"INSERT INTO agents (user_id, name, status) "
"VALUES (:u, 'a', 'draft') RETURNING id"
),
{"u": user_id},
).fetchone()
return str(row[0])
class TestCreateRecurring:
def test_unauthorized(self, app):
from application.api.user.schedules.routes import AgentSchedules
with app.test_request_context(
"/api/agents/x/schedules", method="POST", json={},
):
from flask import request
request.decoded_token = None
resp = AgentSchedules().post("x")
assert resp.status_code == 401
def test_agent_not_found(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
with _patch_db(pg_conn), app.test_request_context(
"/api/agents/00000000-0000-0000-0000-000000000000/schedules",
method="POST", json={"instruction": "x", "cron": "* * * * *"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(
"00000000-0000-0000-0000-000000000000",
)
assert resp.status_code == 404
def test_invalid_cron(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={"instruction": "x", "cron": "not a cron"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 400
def test_create_success(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={
"instruction": "weekly digest",
"cron": "0 9 * * 1",
"timezone": "Europe/Warsaw",
"tool_allowlist": [],
},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 201
body = resp.get_json()
assert body["schedule"]["cron"] == "0 9 * * 1"
assert body["schedule"]["timezone"] == "Europe/Warsaw"
class TestCreateOnce:
def test_creates_once_with_run_at(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
run_at = (_now() + timedelta(hours=2)).isoformat().replace(
"+00:00", "Z",
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={
"instruction": "remind me",
"trigger_type": "once",
"run_at": run_at,
"timezone": "UTC",
"tool_allowlist": [],
},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 201
body = resp.get_json()
assert body["schedule"]["trigger_type"] == "once"
assert body["schedule"]["run_at"] is not None
def test_once_requires_run_at(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={
"instruction": "remind me",
"trigger_type": "once",
},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 400
def test_once_rejects_past_run_at(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
past = (_now() - timedelta(hours=1)).isoformat().replace(
"+00:00", "Z",
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={
"instruction": "x",
"trigger_type": "once",
"run_at": past,
},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 400
def test_recurring_default_when_trigger_type_omitted(self, app, pg_conn):
"""Backwards compat: a payload with cron but no trigger_type still works."""
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={
"instruction": "hourly",
"cron": "0 * * * *",
"timezone": "UTC",
},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 201
assert resp.get_json()["schedule"]["trigger_type"] == "recurring"
class TestListForAgent:
def test_list(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules", method="GET",
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().get(agent_id)
assert resp.status_code == 200
assert len(resp.get_json()["schedules"]) == 1
class TestGetEditPatchDelete:
def _make(self, conn, **kwargs):
return SchedulesRepository(conn).create(**kwargs)
def test_get_owner_scoped(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = self._make(
pg_conn,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="GET",
):
from flask import request
request.decoded_token = {"sub": "u2"}
resp = ScheduleResource().get(str(s["id"]))
assert resp.status_code == 404
def test_pause_then_resume(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = self._make(
pg_conn,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}",
method="PATCH",
json={"action": "pause"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().patch(str(s["id"]))
assert resp.status_code == 200
assert resp.get_json()["schedule"]["status"] == "paused"
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}",
method="PATCH",
json={"action": "resume"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().patch(str(s["id"]))
assert resp.status_code == 200
body = resp.get_json()
assert body["schedule"]["status"] == "active"
assert body["schedule"]["next_run_at"] is not None
def test_delete_owner_scoped(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = self._make(
pg_conn,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="DELETE",
):
from flask import request
request.decoded_token = {"sub": "u2"}
resp = ScheduleResource().delete(str(s["id"]))
assert resp.status_code == 404
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="DELETE",
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().delete(str(s["id"]))
assert resp.status_code == 200
def test_put_invalid_cron(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = self._make(
pg_conn,
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}",
method="PUT", json={"cron": "bad"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().put(str(s["id"]))
assert resp.status_code == 400
class TestRunNow:
def test_runs_returns_202(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleRunNow
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), patch(
"application.api.user.tasks.execute_scheduled_run",
type("T", (), {"apply_async": staticmethod(lambda **k: None)}),
), app.test_request_context(
f"/api/schedules/{s['id']}/run", method="POST",
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleRunNow().post(str(s["id"]))
assert resp.status_code == 202
def test_second_run_blocked_by_active(self, app, pg_conn):
"""Run-Now serializes via FOR UPDATE + has_active_run; second 409s."""
from application.api.user.schedules.routes import ScheduleRunNow
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="0 9 * * 1",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), patch(
"application.api.user.tasks.execute_scheduled_run",
type("T", (), {"apply_async": staticmethod(lambda **k: None)}),
), app.test_request_context(
f"/api/schedules/{s['id']}/run", method="POST",
):
from flask import request
request.decoded_token = {"sub": "u1"}
first = ScheduleRunNow().post(str(s["id"]))
assert first.status_code == 202
second = ScheduleRunNow().post(str(s["id"]))
assert second.status_code == 409
class TestMinInterval:
def test_create_rejects_below_min_interval(self, app, pg_conn):
from application.api.user.schedules.routes import AgentSchedules
agent_id = _make_agent(pg_conn)
with _patch_db(pg_conn), app.test_request_context(
f"/api/agents/{agent_id}/schedules",
method="POST",
json={"instruction": "x", "cron": "* * * * *"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = AgentSchedules().post(agent_id)
assert resp.status_code == 400
assert "minimum interval" in resp.get_json()["message"]
def test_put_rejects_below_min_interval(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="0 9 * * 1",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="PUT",
json={"cron": "*/5 * * * *"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().put(str(s["id"]))
assert resp.status_code == 400
assert "minimum interval" in resp.get_json()["message"]
class TestResumeOnceStale:
def test_stale_run_at_returns_clear_409(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="i", run_at=_now() + timedelta(hours=1),
next_run_at=_now() + timedelta(hours=1),
status="paused",
)
pg_conn.execute(
text(
"UPDATE schedules SET run_at = now() - interval '1 day' "
"WHERE id = CAST(:id AS uuid)"
),
{"id": str(s["id"])},
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="PATCH",
json={"action": "resume"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().patch(str(s["id"]))
assert resp.status_code == 409
assert "elapsed" in resp.get_json()["message"]
def test_resume_accepts_new_run_at(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="once",
instruction="i", run_at=_now() + timedelta(hours=1),
next_run_at=_now() + timedelta(hours=1),
status="paused",
)
pg_conn.execute(
text(
"UPDATE schedules SET run_at = now() - interval '1 day' "
"WHERE id = CAST(:id AS uuid)"
),
{"id": str(s["id"])},
)
new_run_at = (_now() + timedelta(hours=3)).isoformat()
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}", method="PATCH",
json={"action": "resume", "run_at": new_run_at},
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().patch(str(s["id"]))
assert resp.status_code == 200
body = resp.get_json()["schedule"]
assert body["status"] == "active"
class TestRunList:
def test_list_owner_scoped(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleRunList
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/schedules/{s['id']}/runs", method="GET",
):
from flask import request
request.decoded_token = {"sub": "u2"}
resp = ScheduleRunList().get(str(s["id"]))
assert resp.status_code == 404
class TestUnexpectedExceptionMasked:
"""Unexpected exceptions log full trace + return generic 500 (no leak)."""
def test_unexpected_repo_error_returns_generic_500(self, app, pg_conn):
from application.api.user.schedules.routes import ScheduleResource
agent_id = _make_agent(pg_conn)
s = SchedulesRepository(pg_conn).create(
user_id="u1", agent_id=agent_id, trigger_type="recurring",
instruction="i", cron="* * * * *",
next_run_at=_now() + timedelta(hours=1),
)
# Mock the repo's ``get`` to raise an unexpected RuntimeError carrying
# internal-sounding detail; the response must NOT echo that string.
def _boom(*args, **kwargs):
raise RuntimeError("internal detail: secret connection string")
with _patch_db(pg_conn), patch.object(
SchedulesRepository, "get", side_effect=_boom,
), app.test_request_context(
f"/api/schedules/{s['id']}", method="GET",
):
from flask import request
request.decoded_token = {"sub": "u1"}
resp = ScheduleResource().get(str(s["id"]))
assert resp.status_code == 500
body = resp.get_json()
assert body["success"] is False
assert body["message"] == "internal error"
# Defensive: the internal-sounding detail must not leak through.
assert "secret connection string" not in resp.get_data(as_text=True)
+396
View File
@@ -0,0 +1,396 @@
"""Tests for application/api/user/sharing/routes.py.
Post-PG cutover: routes use the PG repositories (ConversationsRepository,
SharedConversationsRepository, AgentsRepository, AttachmentsRepository) and
the ``db_session`` / ``db_readonly`` context managers. These tests use the
ephemeral ``pg_conn`` fixture to exercise real SQL.
"""
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_sharing_db(conn):
@contextmanager
def _yield_conn():
yield conn
with patch(
"application.api.user.sharing.routes.db_session", _yield_conn
), patch(
"application.api.user.sharing.routes.db_readonly", _yield_conn
):
yield
def _seed_conversation(pg_conn, user_id, name="Test Conv", message_count=0):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
repo = ConversationsRepository(pg_conn)
conv = repo.create(user_id, name=name)
conv_id = str(conv["id"])
for i in range(message_count):
repo.append_message(conv_id, {"prompt": f"p{i}", "response": f"r{i}"})
return conv_id
# ---------------------------------------------------------------------------
# ShareConversation — /share endpoint
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestShareConversation:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sharing.routes import ShareConversation
with app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": "x"},
):
from flask import request
request.decoded_token = None
response = ShareConversation().post()
assert response.status_code == 401
def test_returns_400_missing_conversation_id(self, app):
from application.api.user.sharing.routes import ShareConversation
with app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareConversation().post()
assert response.status_code == 400
def test_returns_400_missing_isPromptable(self, app):
from application.api.user.sharing.routes import ShareConversation
with app.test_request_context(
"/api/share",
method="POST",
json={"conversation_id": "x"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareConversation().post()
assert response.status_code == 400
def test_returns_404_for_missing_conversation(self, app, pg_conn):
from application.api.user.sharing.routes import ShareConversation
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": "00000000-0000-0000-0000-000000000000"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareConversation().post()
assert response.status_code == 404
def test_creates_non_promptable_share(self, app, pg_conn):
from application.api.user.sharing.routes import ShareConversation
user = "user-npshare"
conv_id = _seed_conversation(pg_conn, user, message_count=3)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": conv_id},
):
from flask import request
request.decoded_token = {"sub": user}
response = ShareConversation().post()
assert response.status_code == 201
assert response.json["success"] is True
assert "identifier" in response.json
def test_reuse_non_promptable_share_returns_same_identifier(
self, app, pg_conn,
):
from application.api.user.sharing.routes import ShareConversation
user = "user-reuse-np"
conv_id = _seed_conversation(pg_conn, user, message_count=1)
ids = []
for _ in range(2):
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": conv_id},
):
from flask import request
request.decoded_token = {"sub": user}
r = ShareConversation().post()
ids.append(r.json["identifier"])
assert ids[0] == ids[1]
def test_creates_promptable_share(self, app, pg_conn):
from application.api.user.sharing.routes import ShareConversation
user = "user-pshare"
conv_id = _seed_conversation(pg_conn, user, message_count=2)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=true",
method="POST",
json={"conversation_id": conv_id, "chunks": 4},
):
from flask import request
request.decoded_token = {"sub": user}
response = ShareConversation().post()
assert response.status_code == 201
assert response.json["success"] is True
def test_reuse_promptable_share_returns_200(self, app, pg_conn):
from application.api.user.sharing.routes import ShareConversation
user = "user-reuse-p"
conv_id = _seed_conversation(pg_conn, user, message_count=1)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=true",
method="POST",
json={"conversation_id": conv_id, "chunks": 2},
):
from flask import request
request.decoded_token = {"sub": user}
first = ShareConversation().post()
assert first.status_code == 201
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=true",
method="POST",
json={"conversation_id": conv_id, "chunks": 2},
):
from flask import request
request.decoded_token = {"sub": user}
second = ShareConversation().post()
# Second call reuses agent → status 200
assert second.status_code == 200
def test_promptable_with_invalid_chunks_coerces_none(self, app, pg_conn):
from application.api.user.sharing.routes import ShareConversation
user = "user-bad-chunks"
conv_id = _seed_conversation(pg_conn, user, message_count=1)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=true",
method="POST",
json={"conversation_id": conv_id, "chunks": "notanumber"},
):
from flask import request
request.decoded_token = {"sub": user}
response = ShareConversation().post()
assert response.status_code == 201
def test_db_error_returns_400(self, app):
from application.api.user.sharing.routes import ShareConversation
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.sharing.routes.db_session", _broken
), app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": "x"},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = ShareConversation().post()
assert response.status_code == 400
# ---------------------------------------------------------------------------
# GetPubliclySharedConversations — /shared_conversation/<identifier>
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPubliclySharedConversations:
def test_returns_404_for_missing_identifier(self, app, pg_conn):
from application.api.user.sharing.routes import (
GetPubliclySharedConversations,
)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/shared_conversation/abc-does-not-exist"
):
response = GetPubliclySharedConversations().get(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_returns_shared_conversation(self, app, pg_conn):
from application.api.user.sharing.routes import (
GetPubliclySharedConversations,
ShareConversation,
)
user = "user-get-shared"
conv_id = _seed_conversation(pg_conn, user, name="Chat A", message_count=2)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=false",
method="POST",
json={"conversation_id": conv_id},
):
from flask import request
request.decoded_token = {"sub": user}
share_resp = ShareConversation().post()
identifier = share_resp.json["identifier"]
with _patch_sharing_db(pg_conn), app.test_request_context(
f"/api/shared_conversation/{identifier}"
):
response = GetPubliclySharedConversations().get(identifier)
assert response.status_code == 200
data = response.json
assert data["success"] is True
assert data["title"] == "Chat A"
assert isinstance(data["queries"], list)
assert len(data["queries"]) == 2
# Non-promptable share should not expose api_key
assert "api_key" not in data
def test_returns_api_key_for_promptable_share(self, app, pg_conn):
from application.api.user.sharing.routes import (
GetPubliclySharedConversations,
ShareConversation,
)
user = "user-promptable"
conv_id = _seed_conversation(pg_conn, user, message_count=1)
with _patch_sharing_db(pg_conn), app.test_request_context(
"/api/share?isPromptable=true",
method="POST",
json={"conversation_id": conv_id, "chunks": 2},
):
from flask import request
request.decoded_token = {"sub": user}
share_resp = ShareConversation().post()
identifier = share_resp.json["identifier"]
with _patch_sharing_db(pg_conn), app.test_request_context(
f"/api/shared_conversation/{identifier}"
):
response = GetPubliclySharedConversations().get(identifier)
assert response.status_code == 200
assert "api_key" in response.json
assert response.json["api_key"]
def test_db_error_returns_400(self, app):
from application.api.user.sharing.routes import (
GetPubliclySharedConversations,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.sharing.routes.db_readonly", _broken
), app.test_request_context("/api/shared_conversation/abc"):
response = GetPubliclySharedConversations().get("abc")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolvePromptPgId:
def test_returns_none_for_default(self, pg_conn):
from application.api.user.sharing.routes import _resolve_prompt_pg_id
assert _resolve_prompt_pg_id(pg_conn, "default", "u") is None
assert _resolve_prompt_pg_id(pg_conn, "", "u") is None
assert _resolve_prompt_pg_id(pg_conn, None, "u") is None
def test_resolves_uuid_by_ownership(self, pg_conn):
from application.api.user.sharing.routes import _resolve_prompt_pg_id
from application.storage.db.repositories.prompts import PromptsRepository
prompt = PromptsRepository(pg_conn).create("owner", "p", "c")
pid = str(prompt["id"])
assert _resolve_prompt_pg_id(pg_conn, pid, "owner") == pid
# Other user cannot claim
assert _resolve_prompt_pg_id(pg_conn, pid, "someone-else") is None
def test_returns_none_for_unknown_legacy(self, pg_conn):
from application.api.user.sharing.routes import _resolve_prompt_pg_id
assert _resolve_prompt_pg_id(pg_conn, "507f1f77bcf86cd799439011", "u") is None
@pytest.mark.unit
class TestResolveSourcePgId:
def test_returns_none_for_falsy(self, pg_conn):
from application.api.user.sharing.routes import _resolve_source_pg_id
assert _resolve_source_pg_id(pg_conn, None) is None
assert _resolve_source_pg_id(pg_conn, "") is None
def test_returns_none_for_unknown_uuid(self, pg_conn):
from application.api.user.sharing.routes import _resolve_source_pg_id
assert (
_resolve_source_pg_id(pg_conn, "00000000-0000-0000-0000-000000000000")
is None
)
def test_returns_none_for_unknown_legacy(self, pg_conn):
from application.api.user.sharing.routes import _resolve_source_pg_id
assert _resolve_source_pg_id(pg_conn, "507f1f77bcf86cd799439011") is None
+806
View File
@@ -0,0 +1,806 @@
from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import ANY, MagicMock, patch
import pytest
@contextmanager
def _patch_decorator_db(conn):
"""Route the decorator's own ``db_session`` / ``db_readonly`` at ``conn``."""
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.idempotency.db_session", _yield
), patch(
"application.api.user.idempotency.db_readonly", _yield
):
yield
class TestIngestTask:
@pytest.mark.unit
@patch("application.api.user.tasks.ingest_worker")
def test_calls_ingest_worker(self, mock_worker):
from application.api.user.tasks import ingest
mock_worker.return_value = {"status": "ok"}
result = ingest("dir", ["pdf"], "job1", "user1", "/path", "file.pdf")
mock_worker.assert_called_once_with(
ANY, "dir", ["pdf"], "job1", "/path", "file.pdf", "user1",
file_name_map=None, config=None, idempotency_key=None, source_id=None,
)
assert result == {"status": "ok"}
@pytest.mark.unit
@patch("application.api.user.tasks.ingest_worker")
def test_passes_file_name_map(self, mock_worker):
from application.api.user.tasks import ingest
mock_worker.return_value = {"status": "ok"}
name_map = {"a.pdf": "b.pdf"}
ingest("dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
file_name_map=name_map)
mock_worker.assert_called_once_with(
ANY, "dir", ["pdf"], "job1", "/path", "file.pdf", "user1",
file_name_map=name_map, config=None, idempotency_key=None,
source_id=None,
)
class TestIngestRemoteTask:
@pytest.mark.unit
@patch("application.api.user.tasks.remote_worker")
def test_calls_remote_worker(self, mock_worker):
from application.api.user.tasks import ingest_remote
mock_worker.return_value = {"status": "ok"}
result = ingest_remote({"url": "http://x"}, "job1", "user1", "web")
mock_worker.assert_called_once_with(
ANY, {"url": "http://x"}, "job1", "user1", "web",
config=None, idempotency_key=None, source_id=None,
)
assert result == {"status": "ok"}
class TestReingestSourceTask:
@pytest.mark.unit
@patch("application.worker.reingest_source_worker")
def test_calls_reingest_worker(self, mock_worker):
from application.api.user.tasks import reingest_source_task
mock_worker.return_value = {"status": "ok"}
result = reingest_source_task("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"status": "ok"}
class TestConvertSourceToWikiTask:
@pytest.mark.unit
@patch("application.worker.convert_source_to_wiki_worker")
def test_calls_convert_worker(self, mock_worker):
from application.api.user.tasks import convert_source_to_wiki
mock_worker.return_value = {"status": "converted"}
result = convert_source_to_wiki("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"status": "converted"}
class TestExtractGraphTask:
@pytest.mark.unit
@patch("application.worker.extract_graph_worker")
def test_calls_extract_graph_worker(self, mock_worker):
from application.api.user.tasks import extract_graph
mock_worker.return_value = {"nodes": 2, "edges": 1}
result = extract_graph("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"nodes": 2, "edges": 1}
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from application.api.user import tasks
calls: list[str] = []
def _fake_worker(self, source_id, user):
calls.append(source_id)
return {"nodes": 1, "edges": 0}
with _patch_decorator_db(pg_conn), patch(
"application.worker.extract_graph_worker", _fake_worker
):
first = tasks.extract_graph(
"src-g", "user1", idempotency_key="extract-graph:src-g",
)
second = tasks.extract_graph(
"src-g", "user1", idempotency_key="extract-graph:src-g",
)
assert first == second
assert len(calls) == 1
class TestScheduleSyncsTask:
@pytest.mark.unit
@patch("application.api.user.tasks.sync_worker")
def test_calls_sync_worker(self, mock_worker):
from application.api.user.tasks import schedule_syncs
mock_worker.return_value = {"status": "ok"}
result = schedule_syncs("daily")
mock_worker.assert_called_once_with(ANY, "daily")
assert result == {"status": "ok"}
class TestSyncSourceTask:
@pytest.mark.unit
@patch("application.api.user.tasks.sync")
def test_calls_sync(self, mock_sync):
from application.api.user.tasks import sync_source
mock_sync.return_value = {"status": "ok"}
result = sync_source(
{"data": 1}, "job1", "user1", "web", "daily", "classic", "doc1"
)
mock_sync.assert_called_once_with(
ANY, {"data": 1}, "job1", "user1", "web", "daily", "classic", "doc1"
)
assert result == {"status": "ok"}
class TestStoreAttachmentTask:
@pytest.mark.unit
@patch("application.api.user.tasks.attachment_worker")
def test_calls_attachment_worker(self, mock_worker):
from application.api.user.tasks import store_attachment
mock_worker.return_value = {"status": "ok"}
result = store_attachment({"file": "info"}, "user1")
mock_worker.assert_called_once_with(ANY, {"file": "info"}, "user1")
assert result == {"status": "ok"}
class TestProcessAgentWebhookTask:
@pytest.mark.unit
@patch("application.api.user.tasks.agent_webhook_worker")
def test_calls_agent_webhook_worker(self, mock_worker):
from application.api.user.tasks import process_agent_webhook
mock_worker.return_value = {"status": "ok"}
result = process_agent_webhook("agent123", {"event": "test"})
mock_worker.assert_called_once_with(ANY, "agent123", {"event": "test"})
assert result == {"status": "ok"}
class TestIngestConnectorTask:
@pytest.mark.unit
@patch("application.worker.ingest_connector")
def test_calls_ingest_connector_defaults(self, mock_worker):
from application.api.user.tasks import ingest_connector_task
mock_worker.return_value = {"status": "ok"}
result = ingest_connector_task("job1", "user1", "gdrive")
mock_worker.assert_called_once_with(
ANY,
"job1",
"user1",
"gdrive",
session_token=None,
file_ids=None,
folder_ids=None,
recursive=True,
retriever="classic",
operation_mode="upload",
doc_id=None,
sync_frequency="never",
config=None,
idempotency_key=None,
source_id=None,
)
assert result == {"status": "ok"}
@pytest.mark.unit
@patch("application.worker.ingest_connector")
def test_calls_ingest_connector_custom(self, mock_worker):
from application.api.user.tasks import ingest_connector_task
mock_worker.return_value = {"status": "ok"}
result = ingest_connector_task(
"job1",
"user1",
"sharepoint",
session_token="tok",
file_ids=["f1"],
folder_ids=["d1"],
recursive=False,
retriever="duckdb",
operation_mode="sync",
doc_id="doc1",
sync_frequency="daily",
)
mock_worker.assert_called_once_with(
ANY,
"job1",
"user1",
"sharepoint",
session_token="tok",
file_ids=["f1"],
folder_ids=["d1"],
recursive=False,
retriever="duckdb",
operation_mode="sync",
doc_id="doc1",
sync_frequency="daily",
config=None,
idempotency_key=None,
source_id=None,
)
assert result == {"status": "ok"}
class TestSetupPeriodicTasks:
@pytest.mark.unit
def test_registers_periodic_tasks(self):
from application.api.user.tasks import setup_periodic_tasks
sender = MagicMock()
setup_periodic_tasks(sender)
assert sender.add_periodic_task.call_count == 13
calls = sender.add_periodic_task.call_args_list
# daily
assert calls[0][0][0] == timedelta(days=1)
# weekly
assert calls[1][0][0] == timedelta(weeks=1)
# monthly
assert calls[2][0][0] == timedelta(days=30)
# pending_tool_state TTL cleanup (60s)
assert calls[3][0][0] == timedelta(seconds=60)
assert calls[3][1].get("name") == "cleanup-pending-tool-state"
# idempotency dedup TTL cleanup (1h)
assert calls[4][0][0] == timedelta(hours=1)
assert calls[4][1].get("name") == "cleanup-idempotency-dedup"
# reconciliation sweep (30s)
assert calls[5][0][0] == timedelta(seconds=30)
assert calls[5][1].get("name") == "reconciliation"
# version-check (every 7h)
assert calls[6][0][0] == timedelta(hours=7)
# message_events retention sweep (24h)
assert calls[7][0][0] == timedelta(hours=24)
assert calls[7][1].get("name") == "cleanup-message-events"
# orphan memories sweep (24h)
assert calls[8][0][0] == timedelta(hours=24)
assert calls[8][1].get("name") == "cleanup-orphan-memories"
# scheduler dispatcher
assert calls[9][1].get("name") == "dispatch-scheduled-runs"
# schedule runs cleanup (24h)
assert calls[10][0][0] == timedelta(hours=24)
assert calls[10][1].get("name") == "cleanup-schedule-runs"
# sandbox session reaper (60s)
assert calls[11][0][0] == timedelta(seconds=60)
assert calls[11][1].get("name") == "reap-sandbox-sessions"
# stale workflow-run reaper (5m)
assert calls[12][0][0] == timedelta(seconds=300)
assert calls[12][1].get("name") == "reap-stale-workflow-runs"
class TestMcpOauthTask:
@pytest.mark.unit
@patch("application.api.user.tasks.mcp_oauth")
def test_calls_mcp_oauth(self, mock_worker):
from application.api.user.tasks import mcp_oauth_task
mock_worker.return_value = {"url": "http://auth"}
result = mcp_oauth_task({"server": "mcp"}, "user1")
mock_worker.assert_called_once_with(ANY, {"server": "mcp"}, "user1")
assert result == {"url": "http://auth"}
class TestParseDocumentTask:
"""parse_document runs on the parsing queue under a bounded time limit."""
@pytest.mark.unit
@patch("application.api.user.tasks.parse_document_worker")
def test_calls_parse_document_worker(self, mock_worker):
from application.api.user.tasks import parse_document
mock_worker.return_value = {"status": "ok", "content": "hi"}
result = parse_document(
"art-1", {"conversation_id": "c1"}, "user1", {"output": "markdown"},
)
mock_worker.assert_called_once_with(
ANY, "art-1", {"conversation_id": "c1"}, "user1",
{"output": "markdown"},
)
assert result == {"status": "ok", "content": "hi"}
@pytest.mark.unit
@patch("application.api.user.tasks.parse_document_worker")
def test_soft_time_limit_returns_clean_error(self, mock_worker):
from celery.exceptions import SoftTimeLimitExceeded
from application.api.user.tasks import parse_document
mock_worker.side_effect = SoftTimeLimitExceeded("parse")
# The task must swallow the soft-limit signal and return the worker's
# clean error shape so the parsing-worker slot frees instead of crashing.
result = parse_document("art-1", {"conversation_id": "c1"}, "user1")
assert result["status"] == "error"
assert "timed out" in result["error"]
@pytest.mark.unit
def test_time_limits_derived_from_document_parse_timeout(self):
from application.api.user.tasks import parse_document
from application.core.settings import settings
assert parse_document.soft_time_limit == settings.DOCUMENT_PARSE_TIMEOUT
assert parse_document.time_limit == settings.DOCUMENT_PARSE_TIMEOUT + 30
# Hard limit must exceed the soft limit so the handler can unwind first.
assert parse_document.time_limit > parse_document.soft_time_limit
class TestDurableTaskRetryPolicy:
"""The long-running tasks share a uniform retry policy."""
@pytest.mark.unit
@pytest.mark.parametrize(
"task_name",
[
"ingest",
"ingest_remote",
"reingest_source_task",
"store_attachment",
"process_agent_webhook",
"ingest_connector_task",
"reembed_wiki_page",
"convert_source_to_wiki",
"extract_graph",
],
)
def test_task_has_retry_config(self, task_name):
import application.api.user.tasks as tasks_module
task = getattr(tasks_module, task_name)
assert task.acks_late is True
assert Exception in task.autoretry_for
assert task.retry_backoff is True
assert task.retry_kwargs == {"max_retries": 3, "countdown": 60}
@pytest.mark.unit
@pytest.mark.parametrize(
"task_name",
[
"schedule_syncs",
"sync_source",
"mcp_oauth_task",
"cleanup_pending_tool_state",
"reconciliation_task",
"version_check_task",
"cleanup_orphan_memories",
],
)
def test_short_periodic_tasks_have_no_retry_config(self, task_name):
import application.api.user.tasks as tasks_module
task = getattr(tasks_module, task_name)
assert not getattr(task, "autoretry_for", None)
class TestProcessAgentWebhookIdempotency:
"""Wrapper short-circuits a second call with the same key on the durable webhook task."""
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from application.api.user.tasks import process_agent_webhook
worker_calls = []
def _fake_worker(self, agent_id, payload):
worker_calls.append((agent_id, payload))
return {"status": "success", "result": {"answer": "ok"}}
with _patch_decorator_db(pg_conn), patch(
"application.api.user.tasks.agent_webhook_worker",
side_effect=_fake_worker,
):
first = process_agent_webhook(
"agent", {"event": "x"}, idempotency_key="dur-k1",
)
second = process_agent_webhook(
"agent", {"event": "x"}, idempotency_key="dur-k1",
)
assert first == {"status": "success", "result": {"answer": "ok"}}
assert second == first
assert len(worker_calls) == 1
class TestCleanupPendingToolState:
"""Janitor reverts stale 'resuming' rows and deletes TTL-expired rows."""
@pytest.mark.unit
def test_reverts_stale_and_deletes_expired(self, pg_conn):
from sqlalchemy import text as _text
from application.api.user.tasks import cleanup_pending_tool_state
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
from application.storage.db.repositories.pending_tool_state import (
PendingToolStateRepository,
)
repo = PendingToolStateRepository(pg_conn)
def _sample() -> dict:
return {
"messages": [],
"pending_tool_calls": [],
"tools_dict": {},
"tool_schemas": [],
"agent_config": {},
}
# Pending and fresh — should be left alone.
c1 = ConversationsRepository(pg_conn).create("u", "fresh-pending")
repo.save_state(c1["id"], "u", **_sample())
# Pending but already expired — should be deleted.
c2 = ConversationsRepository(pg_conn).create("u", "expired-pending")
repo.save_state(c2["id"], "u", **_sample(), ttl_seconds=0)
# Resuming within grace — should stay 'resuming'.
c3 = ConversationsRepository(pg_conn).create("u", "fresh-resuming")
repo.save_state(c3["id"], "u", **_sample())
repo.mark_resuming(c3["id"], "u")
# Resuming past grace — should revert to 'pending'.
c4 = ConversationsRepository(pg_conn).create("u", "stale-resuming")
repo.save_state(c4["id"], "u", **_sample())
repo.mark_resuming(c4["id"], "u")
pg_conn.execute(
_text(
"UPDATE pending_tool_state "
"SET resumed_at = clock_timestamp() "
" - make_interval(secs => 660) "
"WHERE conversation_id = CAST(:conv_id AS uuid)"
),
{"conv_id": c4["id"]},
)
from contextlib import contextmanager
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"application.storage.db.engine.get_engine",
return_value=fake_engine,
):
result = cleanup_pending_tool_state.run()
assert result["reverted"] == 1
assert result["deleted"] == 1
# Final state assertions.
assert repo.load_state(c1["id"], "u")["status"] == "pending"
assert repo.load_state(c2["id"], "u") is None
assert repo.load_state(c3["id"], "u")["status"] == "resuming"
c4_row = repo.load_state(c4["id"], "u")
assert c4_row["status"] == "pending"
assert c4_row["resumed_at"] is None
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from application.api.user.tasks import cleanup_pending_tool_state
from application.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_pending_tool_state.run()
assert result == {
"deleted": 0,
"reverted": 0,
"skipped": "POSTGRES_URI not set",
}
class TestCleanupMessageEventsTask:
"""Retention janitor delegates to MessageEventsRepository.cleanup_older_than."""
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from application.api.user.tasks import cleanup_message_events
from application.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_message_events.run()
assert result == {"deleted": 0, "skipped": "POSTGRES_URI not set"}
@pytest.mark.unit
def test_deletes_rows_past_retention_window(self, pg_conn, monkeypatch):
import uuid
from sqlalchemy import text as _text
from application.api.user.tasks import cleanup_message_events
from application.core.settings import settings
from application.storage.db.repositories.message_events import (
MessageEventsRepository,
)
# Seed parent rows so the FK on message_events holds.
user_id = f"user-{uuid.uuid4().hex[:8]}"
conv_id = uuid.uuid4()
msg_id = uuid.uuid4()
pg_conn.execute(
_text("INSERT INTO users (user_id) VALUES (:u)"),
{"u": user_id},
)
pg_conn.execute(
_text(
"INSERT INTO conversations (id, user_id, name) "
"VALUES (:id, :u, 'test')"
),
{"id": conv_id, "u": user_id},
)
pg_conn.execute(
_text(
"INSERT INTO conversation_messages (id, conversation_id, "
"user_id, position) VALUES (:id, :c, :u, 0)"
),
{"id": msg_id, "c": conv_id, "u": user_id},
)
repo = MessageEventsRepository(pg_conn)
repo.record(str(msg_id), 0, "answer", {"chunk": "stale"})
repo.record(str(msg_id), 1, "answer", {"chunk": "fresh"})
# Backdate seq=0 past the default 14-day retention so the
# janitor catches it; seq=1 stays at "now" and must survive.
pg_conn.execute(
_text(
"UPDATE message_events SET created_at = now() - interval '20 days' "
"WHERE message_id = CAST(:id AS uuid) AND sequence_no = 0"
),
{"id": str(msg_id)},
)
monkeypatch.setattr(
settings, "POSTGRES_URI", "postgresql://stub", raising=False
)
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"application.storage.db.engine.get_engine",
return_value=fake_engine,
):
result = cleanup_message_events.run()
assert result == {
"deleted": 1,
"ttl_days": settings.MESSAGE_EVENTS_RETENTION_DAYS,
}
# Only the fresh row survives.
rows = repo.read_after(str(msg_id))
assert [r["sequence_no"] for r in rows] == [1]
class TestCleanupOrphanMemoriesTask:
"""Sweeps orphan memories from the FK-to-trigger orphan window."""
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from application.api.user.tasks import cleanup_orphan_memories
from application.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_orphan_memories.run()
assert result == {"deleted": 0, "skipped": "POSTGRES_URI not set"}
@pytest.mark.unit
def test_deletes_orphan_keeps_synthetic_and_live(
self, pg_conn, monkeypatch
):
import uuid
from sqlalchemy import text as _text
from application.agents.default_tools import default_tool_id
from application.api.user.tasks import cleanup_orphan_memories
from application.core.settings import settings
from application.storage.db.repositories.memories import (
MemoriesRepository,
)
repo = MemoriesRepository(pg_conn)
synthetic_id = default_tool_id("memory")
live_id = str(
pg_conn.execute(
_text(
"INSERT INTO user_tools (user_id, name) "
"VALUES ('u-task-mem', 'memory') RETURNING id"
)
).scalar()
)
orphan_id = str(uuid.uuid4())
repo.upsert("u-task-mem", synthetic_id, "/syn.txt", "keep")
repo.upsert("u-task-mem", live_id, "/live.txt", "keep")
repo.upsert("u-task-mem", orphan_id, "/orphan.txt", "drop")
monkeypatch.setattr(
settings, "POSTGRES_URI", "postgresql://stub", raising=False
)
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"application.storage.db.engine.get_engine",
return_value=fake_engine,
):
result = cleanup_orphan_memories.run()
assert result == {"deleted": 1}
assert repo.get_by_path("u-task-mem", synthetic_id, "/syn.txt")
assert repo.get_by_path("u-task-mem", live_id, "/live.txt")
assert repo.get_by_path("u-task-mem", orphan_id, "/orphan.txt") is None
class TestIngestIdempotency:
"""Same short-circuit applies to the ingest task path."""
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from application.api.user.tasks import ingest
worker_calls = []
def _fake_worker(self, directory, formats, job_name, file_path,
filename, user, file_name_map=None, config=None,
idempotency_key=None, source_id=None):
worker_calls.append(filename)
return {"status": "ok", "directory": directory}
with _patch_decorator_db(pg_conn), patch(
"application.api.user.tasks.ingest_worker",
side_effect=_fake_worker,
):
first = ingest(
"dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
idempotency_key="dur-ing-1",
)
second = ingest(
"dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
idempotency_key="dur-ing-1",
)
assert first == second
assert first == {"status": "ok", "directory": "dir"}
assert len(worker_calls) == 1
class TestIngestPoisonEvent:
"""The poison hook publishes a terminal source.ingest.failed so the
upload toast resolves instead of hanging on "training".
"""
@pytest.mark.unit
def test_publishes_failed_event(self):
from application.api.user.tasks import _emit_ingest_poison_event
published = []
def _fake_publish(user, event_type, payload, *, scope=None):
published.append((user, event_type, payload, scope))
with patch(
"application.events.publisher.publish_user_event",
side_effect=_fake_publish,
):
_emit_ingest_poison_event(
"ingest",
{"user": "u1", "source_id": "src-9", "filename": "doc.pdf"},
)
assert len(published) == 1
user, event_type, payload, scope = published[0]
assert user == "u1"
assert event_type == "source.ingest.failed"
assert payload["source_id"] == "src-9"
assert payload["filename"] == "doc.pdf"
assert payload["operation"] == "upload"
assert scope == {"kind": "source", "id": "src-9"}
@pytest.mark.unit
def test_skips_when_source_id_missing(self):
from application.api.user.tasks import _emit_ingest_poison_event
with patch(
"application.events.publisher.publish_user_event",
) as mock_publish:
_emit_ingest_poison_event("ingest", {"user": "u1"})
mock_publish.assert_not_called()
@pytest.mark.unit
def test_reingest_uses_reingest_operation(self):
from application.api.user.tasks import _emit_ingest_poison_event
published = []
with patch(
"application.events.publisher.publish_user_event",
side_effect=lambda *a, **k: published.append((a, k)),
):
_emit_ingest_poison_event(
"reingest_source_task",
{"user": "u1", "source_id": "src-r"},
)
assert published[0][0][2]["operation"] == "reingest"
@pytest.mark.unit
def test_bare_worker_consumes_app_and_parsing_queues():
"""task_queues declares every queue, so a worker started without -Q serves
both app tasks and document parsing — a -Q-less dev worker must never
silently strand attachment uploads or parse_document tasks."""
import application.celeryconfig as celeryconfig
from application.core.settings import settings
names = {queue.name for queue in celeryconfig.task_queues}
assert "docsgpt" in names
assert settings.DOCUMENT_PARSE_QUEUE in names
@@ -0,0 +1,107 @@
"""Tests for the generalized GET /api/tools/artifact/<id> document branch."""
from __future__ import annotations
import uuid
from contextlib import contextmanager
import pytest
from flask import request
from application.storage.db.repositories.artifacts import ArtifactsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.notes import NotesRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
@pytest.fixture
def _patch_db(pg_conn, monkeypatch):
"""Point both the tools and artifacts route DB helpers at the test conn."""
@contextmanager
def _use_conn():
yield pg_conn
monkeypatch.setattr("application.api.user.tools.routes.db_readonly", _use_conn)
monkeypatch.setattr("application.api.user.artifacts.routes.db_readonly", _use_conn)
return pg_conn
def _get(flask_app, artifact_id, token):
from application.api.user.tools.routes import GetArtifact
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = token
return GetArtifact().get(artifact_id)
@pytest.mark.unit
class TestGeneralizedToolsArtifact:
def test_document_branch_returns_metadata(
self, _patch_db, flask_app, decoded_token
):
conv = ConversationsRepository(_patch_db).create(
decoded_token["sub"], name="conv"
)
art = ArtifactsRepository(_patch_db).create_artifact(
decoded_token["sub"],
"document",
conversation_id=str(conv["id"]),
title="Report",
filename="report.pdf",
mime_type="application/pdf",
storage_path="inputs/u/artifacts/x/v1/report.pdf",
)
resp = _get(flask_app, art["id"], decoded_token)
assert resp.status_code == 200
body = resp.json["artifact"]
assert body["artifact_type"] == "document"
assert body["data"]["title"] == "Report"
assert body["data"]["filename"] == "report.pdf"
assert body["data"]["download_url"] == f"/api/artifacts/{art['id']}/download"
def test_file_kind_maps_to_file_type(self, _patch_db, flask_app, decoded_token):
conv = ConversationsRepository(_patch_db).create(
decoded_token["sub"], name="conv"
)
art = ArtifactsRepository(_patch_db).create_artifact(
decoded_token["sub"],
"file",
conversation_id=str(conv["id"]),
storage_path="inputs/u/artifacts/y/v1/data.bin",
)
resp = _get(flask_app, art["id"], decoded_token)
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "file"
def test_stranger_document_not_found(self, _patch_db, flask_app, decoded_token):
conv = ConversationsRepository(_patch_db).create("other_owner", name="conv")
art = ArtifactsRepository(_patch_db).create_artifact(
"other_owner",
"document",
conversation_id=str(conv["id"]),
storage_path="inputs/o/artifacts/z/v1/x.pdf",
)
# Parent-derived authz denies a stranger -> falls through to 404.
resp = _get(flask_app, art["id"], decoded_token)
assert resp.status_code == 404
def test_note_branch_still_works(self, _patch_db, flask_app, decoded_token):
tool = UserToolsRepository(_patch_db).create(
user_id=decoded_token["sub"], name="notes_tool"
)
note = NotesRepository(_patch_db).upsert(
user_id=decoded_token["sub"],
tool_id=str(tool["id"]),
title="t",
content="a\nb",
)
resp = _get(flask_app, str(note["id"]), decoded_token)
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "note"
def test_unknown_id_404(self, _patch_db, flask_app, decoded_token):
resp = _get(flask_app, str(uuid.uuid4()), decoded_token)
assert resp.status_code == 404
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
"""Tests for application/api/user/tools/mcp.py using real PG."""
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.tools.mcp.db_session", _yield
), patch(
"application.api.user.tools.mcp.db_readonly", _yield
):
yield
class TestSanitizeMcpTransport:
def test_defaults_to_auto(self):
from application.api.user.tools.mcp import _sanitize_mcp_transport
cfg = {}
got = _sanitize_mcp_transport(cfg)
assert got == "auto"
assert cfg["transport_type"] == "auto"
def test_accepts_supported_transports(self):
from application.api.user.tools.mcp import _sanitize_mcp_transport
for t in ("auto", "sse", "http"):
cfg = {"transport_type": t}
assert _sanitize_mcp_transport(cfg) == t
def test_strips_command_and_args(self):
from application.api.user.tools.mcp import _sanitize_mcp_transport
cfg = {"transport_type": "http", "command": "/bin/x", "args": ["a"]}
_sanitize_mcp_transport(cfg)
assert "command" not in cfg
assert "args" not in cfg
def test_unsupported_transport_raises(self):
from application.api.user.tools.mcp import _sanitize_mcp_transport
with pytest.raises(ValueError):
_sanitize_mcp_transport({"transport_type": "websocket"})
class TestExtractAuthCredentials:
def test_api_key_auth(self):
from application.api.user.tools.mcp import _extract_auth_credentials
got = _extract_auth_credentials({
"auth_type": "api_key",
"api_key": "secret",
"api_key_header": "X-API-Key",
})
assert got == {"api_key": "secret", "api_key_header": "X-API-Key"}
def test_bearer_auth(self):
from application.api.user.tools.mcp import _extract_auth_credentials
got = _extract_auth_credentials({
"auth_type": "bearer",
"bearer_token": "my-token",
})
assert got == {"bearer_token": "my-token"}
def test_basic_auth(self):
from application.api.user.tools.mcp import _extract_auth_credentials
got = _extract_auth_credentials({
"auth_type": "basic",
"username": "u", "password": "p",
})
assert got == {"username": "u", "password": "p"}
def test_none_auth_empty_creds(self):
from application.api.user.tools.mcp import _extract_auth_credentials
assert _extract_auth_credentials({"auth_type": "none"}) == {}
class TestValidateMcpServerUrl:
def test_empty_url_raises(self):
from application.api.user.tools.mcp import _validate_mcp_server_url
with pytest.raises(ValueError):
_validate_mcp_server_url({})
def test_missing_server_url(self):
from application.api.user.tools.mcp import _validate_mcp_server_url
with pytest.raises(ValueError):
_validate_mcp_server_url({"server_url": ""})
def test_ssrf_url_raises(self):
from application.api.user.tools.mcp import _validate_mcp_server_url
with pytest.raises(ValueError):
_validate_mcp_server_url({"server_url": "http://127.0.0.1"})
def test_valid_public_url_passes(self):
from application.api.user.tools.mcp import _validate_mcp_server_url
# Should not raise for a public-ish URL
try:
_validate_mcp_server_url({"server_url": "https://example.com/mcp"})
except ValueError as e:
# If SSRF rules reject example.com for some reason, accept that
if "Invalid" not in str(e):
raise
class TestTestMCPServerConfig:
def test_returns_401_unauthenticated(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
with app.test_request_context(
"/api/mcp_server/test", method="POST",
json={"config": {}},
):
from flask import request
request.decoded_token = None
response = TestMCPServerConfig().post()
assert response.status_code == 401
def test_returns_400_missing_config(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
with app.test_request_context(
"/api/mcp_server/test", method="POST", json={},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 400
def test_unsupported_transport_returns_400(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
with app.test_request_context(
"/api/mcp_server/test", method="POST",
json={"config": {"transport_type": "websocket"}},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 400
def test_missing_url_returns_400(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
with app.test_request_context(
"/api/mcp_server/test", method="POST",
json={"config": {"transport_type": "http"}},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 400
def test_connection_success(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
fake_tool = MagicMock()
fake_tool.test_connection.return_value = {
"success": True, "message": "OK",
"tools_count": 3, "tools": ["a", "b", "c"],
}
with patch(
"application.api.user.tools.mcp.MCPTool",
return_value=fake_tool,
), app.test_request_context(
"/api/mcp_server/test", method="POST",
json={
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
"auth_type": "none",
},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 200
assert response.json["success"] is True
assert response.json["tools_count"] == 3
def test_connection_failure_returns_200_with_failure_message(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
fake_tool = MagicMock()
fake_tool.test_connection.return_value = {
"success": False, "message": "Cannot reach server",
}
with patch(
"application.api.user.tools.mcp.MCPTool",
return_value=fake_tool,
), app.test_request_context(
"/api/mcp_server/test", method="POST",
json={
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 200
assert response.json["success"] is False
def test_oauth_required_returns_200(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
fake_tool = MagicMock()
fake_tool.test_connection.return_value = {
"success": False,
"requires_oauth": True,
"auth_url": "https://auth/ex",
}
with patch(
"application.api.user.tools.mcp.MCPTool",
return_value=fake_tool,
), app.test_request_context(
"/api/mcp_server/test", method="POST",
json={
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
"auth_type": "oauth",
},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 200
assert response.json["requires_oauth"] is True
def test_unexpected_exception_returns_500(self, app):
from application.api.user.tools.mcp import TestMCPServerConfig
with patch(
"application.api.user.tools.mcp.MCPTool",
side_effect=RuntimeError("boom"),
), app.test_request_context(
"/api/mcp_server/test", method="POST",
json={
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = TestMCPServerConfig().post()
assert response.status_code == 500
class TestMCPServerSave:
def test_returns_401_unauthenticated(self, app):
from application.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
"/api/mcp_server/save", method="POST",
json={"displayName": "n", "config": {}},
):
from flask import request
request.decoded_token = None
response = MCPServerSave().post()
assert response.status_code == 401
def test_returns_400_missing_fields(self, app):
from application.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
"/api/mcp_server/save", method="POST", json={},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MCPServerSave().post()
assert response.status_code == 400
def test_unsupported_transport_returns_400(self, app):
from application.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
"/api/mcp_server/save", method="POST",
json={
"displayName": "Srv",
"config": {"transport_type": "bogus"},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MCPServerSave().post()
assert response.status_code == 400
def test_missing_server_url_returns_400(self, app):
from application.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
"/api/mcp_server/save", method="POST",
json={"displayName": "Srv", "config": {"transport_type": "http"}},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MCPServerSave().post()
assert response.status_code == 400
def test_oauth_missing_task_id_returns_400(self, app):
from application.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
"/api/mcp_server/save", method="POST",
json={
"displayName": "Srv",
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
"auth_type": "oauth",
},
},
):
from flask import request
request.decoded_token = {"sub": "u"}
response = MCPServerSave().post()
assert response.status_code == 400
def test_creates_mcp_tool_successfully(self, app, pg_conn):
from application.api.user.tools.mcp import MCPServerSave
user = "u-mcp-save"
fake_tool = MagicMock()
fake_tool.discover_tools.return_value = {"tools": ["t1"]}
fake_tool.get_actions_metadata.return_value = [{"name": "t1"}]
with _patch_db(pg_conn), patch(
"application.api.user.tools.mcp.MCPTool",
return_value=fake_tool,
), app.test_request_context(
"/api/mcp_server/save", method="POST",
json={
"displayName": "My MCP",
"config": {
"transport_type": "http",
"server_url": "https://example.com/mcp",
"auth_type": "none",
},
"status": True,
},
):
from flask import request
request.decoded_token = {"sub": user}
response = MCPServerSave().post()
assert response.status_code in (200, 201)
class TestMCPOAuthCallback:
def test_error_param_redirects_error(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
with app.test_request_context(
"/api/mcp_server/oauth_callback?error=access_denied"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
assert "status=error" in response.location
def test_missing_code_or_state_redirects_error(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
with app.test_request_context(
"/api/mcp_server/oauth_callback"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
def test_success_redirects_success(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
fake_redis = MagicMock()
fake_manager = MagicMock()
fake_manager.handle_oauth_callback.return_value = True
with patch(
"application.api.user.tools.mcp.get_redis_instance",
return_value=fake_redis,
), patch(
"application.api.user.tools.mcp.MCPOAuthManager",
return_value=fake_manager,
), app.test_request_context(
"/api/mcp_server/oauth_callback?code=c1&state=s1"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
assert "status=success" in response.location
def test_manager_failure_redirects_error(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
fake_redis = MagicMock()
fake_manager = MagicMock()
fake_manager.handle_oauth_callback.return_value = False
with patch(
"application.api.user.tools.mcp.get_redis_instance",
return_value=fake_redis,
), patch(
"application.api.user.tools.mcp.MCPOAuthManager",
return_value=fake_manager,
), app.test_request_context(
"/api/mcp_server/oauth_callback?code=c1&state=s1"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
assert "status=error" in response.location
def test_no_redis_redirects_error(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
with patch(
"application.api.user.tools.mcp.get_redis_instance",
return_value=None,
), app.test_request_context(
"/api/mcp_server/oauth_callback?code=c&state=s"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
assert "Redis" in response.location or "status=error" in response.location
def test_exception_redirects_error(self, app):
from application.api.user.tools.mcp import MCPOAuthCallback
with patch(
"application.api.user.tools.mcp.get_redis_instance",
side_effect=RuntimeError("boom"),
), app.test_request_context(
"/api/mcp_server/oauth_callback?code=c&state=s"
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
class TestMCPAuthStatus:
def test_returns_401_unauthenticated(self, app):
from application.api.user.tools.mcp import MCPAuthStatus
with app.test_request_context(
"/api/mcp_server/auth_status"
):
from flask import request
request.decoded_token = None
response = MCPAuthStatus().get()
assert response.status_code == 401
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,688 @@
"""Tests for the BYOM REST API at /api/user/models."""
from __future__ import annotations
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):
"""Patch the routes' db helpers to yield the given pg connection."""
@contextmanager
def _yield_conn():
yield conn
with patch(
"application.api.user.models.routes.db_session", _yield_conn
), patch(
"application.api.user.models.routes.db_readonly", _yield_conn
):
yield
@pytest.fixture(autouse=True)
def _reset_registry():
from application.core.model_registry import ModelRegistry
ModelRegistry.reset()
yield
ModelRegistry.reset()
# Auth
@pytest.mark.unit
class TestAuth:
def test_list_unauthenticated_returns_401(self, app):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with app.test_request_context("/api/user/models"):
from flask import request
request.decoded_token = None
resp = UserModelsCollectionResource().get()
assert resp.status_code == 401
def test_create_unauthenticated_returns_401(self, app):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "x",
"display_name": "x",
"base_url": "https://api.openai.com/v1",
"api_key": "k",
},
):
from flask import request
request.decoded_token = None
resp = UserModelsCollectionResource().post()
assert resp.status_code == 401
# Create
@pytest.mark.unit
class TestCreate:
def test_creates_and_returns_201_without_api_key(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
# Mock DNS so the SSRF check passes for api.mistral.ai without
# hitting the network.
with patch("application.security.safe_url.socket.getaddrinfo") as gai:
gai.return_value = [
(None, None, None, None, ("104.18.0.1", 0))
]
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "mistral-large-latest",
"display_name": "My Mistral",
"base_url": "https://api.mistral.ai/v1",
"api_key": "sk-mistral-test",
"capabilities": {
"supports_tools": True,
"context_window": 128000,
},
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 201
body = resp.get_json()
assert body["upstream_model_id"] == "mistral-large-latest"
assert body["source"] == "user"
# Critical: api_key must NEVER appear in the response
assert "api_key" not in body
for v in body.values():
assert v != "sk-mistral-test"
def test_create_rejects_missing_required_fields(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with app.test_request_context(
"/api/user/models",
method="POST",
json={"upstream_model_id": "x"}, # missing the others
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 400
def test_create_rejects_loopback_url(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "x",
"display_name": "x",
"base_url": "https://127.0.0.1/v1",
"api_key": "k",
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 400
body = resp.get_json()
assert "error" in body
def test_create_rejects_unknown_attachment_alias(self, app, pg_conn):
"""The UI sends ``["image"]`` as an alias; bad strings ("video",
typos) must reject at the boundary so the DB never holds
garbage that the registry would later silently drop.
"""
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with patch("application.security.safe_url.socket.getaddrinfo") as gai:
gai.return_value = [
(None, None, None, None, ("104.18.0.1", 0))
]
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "m",
"display_name": "M",
"base_url": "https://api.mistral.ai/v1",
"api_key": "k",
"capabilities": {"attachments": ["video"]},
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 400
body = resp.get_json()
assert "video" in body["error"]
def test_create_accepts_image_alias_and_raw_mime(self, app, pg_conn):
"""The known ``image`` alias and raw MIME types both pass."""
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with patch("application.security.safe_url.socket.getaddrinfo") as gai:
gai.return_value = [
(None, None, None, None, ("104.18.0.1", 0))
]
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "m",
"display_name": "M",
"base_url": "https://api.mistral.ai/v1",
"api_key": "k",
"capabilities": {
"attachments": ["image", "application/pdf"],
},
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 201
def test_create_rejects_private_ip_dns(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with patch("application.security.safe_url.socket.getaddrinfo") as gai:
# Hostname resolves to a private IP only — must reject
gai.return_value = [
(None, None, None, None, ("10.0.0.5", 0))
]
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "x",
"display_name": "x",
"base_url": "https://evil.example.com/v1",
"api_key": "k",
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 400
# List / get / patch / delete
def _create_via_repo(pg_conn, user_id="user-1", **kwargs):
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
return UserCustomModelsRepository(pg_conn).create(
user_id=user_id,
upstream_model_id=kwargs.pop("upstream_model_id", "mistral-large-latest"),
display_name=kwargs.pop("display_name", "My Mistral"),
base_url=kwargs.pop("base_url", "https://api.mistral.ai/v1"),
api_key_plaintext=kwargs.pop("api_key_plaintext", "sk-mistral-test"),
**kwargs,
)
@pytest.mark.unit
class TestList:
def test_lists_only_users_own(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
_create_via_repo(pg_conn, user_id="alice", upstream_model_id="alice-1")
_create_via_repo(pg_conn, user_id="bob", upstream_model_id="bob-1")
with app.test_request_context("/api/user/models"):
from flask import request
request.decoded_token = {"sub": "alice"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().get()
assert resp.status_code == 200
body = resp.get_json()
upstream_ids = {m["upstream_model_id"] for m in body["models"]}
assert upstream_ids == {"alice-1"}
# Never expose the api_key
for m in body["models"]:
assert "api_key" not in m
@pytest.mark.unit
class TestGet:
def test_returns_404_for_other_users_model(self, app, pg_conn):
from application.api.user.models.routes import UserModelResource
created = _create_via_repo(pg_conn, user_id="alice")
with app.test_request_context(
f"/api/user/models/{created['id']}"
):
from flask import request
request.decoded_token = {"sub": "bob"}
with _patch_db(pg_conn):
resp = UserModelResource().get(model_id=created["id"])
assert resp.status_code == 404
@pytest.mark.unit
class TestPatch:
def test_patch_updates_display_name(self, app, pg_conn):
from application.api.user.models.routes import UserModelResource
created = _create_via_repo(pg_conn, user_id="user-1")
with app.test_request_context(
f"/api/user/models/{created['id']}",
method="PATCH",
json={"display_name": "Renamed"},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelResource().patch(model_id=created["id"])
assert resp.status_code == 200
body = resp.get_json()
assert body["display_name"] == "Renamed"
def test_patch_blank_api_key_keeps_existing(self, app, pg_conn):
"""Critical PATCH semantic: empty/missing api_key in body must
preserve the stored ciphertext (the UI sends a blank password
field when the user wants to keep the existing key)."""
from application.api.user.models.routes import UserModelResource
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
created = _create_via_repo(pg_conn, user_id="user-1")
original_key_plaintext = "sk-mistral-test"
with app.test_request_context(
f"/api/user/models/{created['id']}",
method="PATCH",
json={"display_name": "Just rename me", "api_key": ""},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelResource().patch(model_id=created["id"])
assert resp.status_code == 200
# Decrypted key is unchanged
repo = UserCustomModelsRepository(pg_conn)
assert (
repo.get_decrypted_api_key(created["id"], "user-1")
== original_key_plaintext
)
@pytest.mark.unit
class TestDelete:
def test_delete_removes_row_and_invalidates_cache(self, app, pg_conn):
from application.api.user.models.routes import UserModelResource
from application.core.model_registry import ModelRegistry
created = _create_via_repo(pg_conn, user_id="user-1")
# Warm the registry's per-user cache via a lookup
with patch(
"application.storage.db.session.db_readonly"
) as ro:
@contextmanager
def _y():
yield pg_conn
ro.side_effect = _y
ModelRegistry.get_instance().get_model(created["id"], user_id="user-1")
# Now delete via the route — invalidation must happen so a
# subsequent lookup misses
with app.test_request_context(
f"/api/user/models/{created['id']}", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelResource().delete(model_id=created["id"])
assert resp.status_code == 200
# Cache invalidated → next lookup re-queries DB and finds nothing
assert "user-1" not in ModelRegistry.get_instance()._user_models
# /api/models combined view
@pytest.mark.unit
class TestSecurityCreateRejectsBlankFields:
"""P1 #1 partial: blank api_key on create must be rejected so we
can never end up with an unroutable BYOM record that would cause
LLMCreator to leak settings.API_KEY to the user-supplied URL."""
def test_create_rejects_blank_api_key(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelsCollectionResource,
)
with patch("application.security.safe_url.socket.getaddrinfo") as gai:
gai.return_value = [(None, None, None, None, ("104.18.0.1", 0))]
with app.test_request_context(
"/api/user/models",
method="POST",
json={
"upstream_model_id": "x",
"display_name": "x",
"base_url": "https://api.mistral.ai/v1",
"api_key": " ", # whitespace only
},
):
from flask import request
request.decoded_token = {"sub": "u"}
with _patch_db(pg_conn):
resp = UserModelsCollectionResource().post()
assert resp.status_code == 400
body = resp.get_json()
assert "api_key" in (body.get("error") or "").lower()
def test_patch_rejects_blank_required_field(self, app, pg_conn):
from application.api.user.models.routes import UserModelResource
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
created = UserCustomModelsRepository(pg_conn).create(
user_id="u",
upstream_model_id="x",
display_name="x",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-x",
)
with app.test_request_context(
f"/api/user/models/{created['id']}",
method="PATCH",
json={"base_url": " "},
):
from flask import request
request.decoded_token = {"sub": "u"}
with _patch_db(pg_conn):
resp = UserModelResource().patch(model_id=created["id"])
assert resp.status_code == 400
@pytest.mark.unit
class TestPayloadConnectionTest:
"""Verifies the payload-based test endpoint. Lets the UI's 'Test
connection' button work *before* the model is saved — operators
expect to validate their endpoint + key before committing."""
def test_payload_test_rejects_unsafe_url(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelTestPayloadResource,
)
with app.test_request_context(
"/api/user/models/test",
method="POST",
json={
"base_url": "https://127.0.0.1/v1",
"api_key": "sk-anything",
"upstream_model_id": "x",
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelTestPayloadResource().post()
assert resp.status_code == 400
body = resp.get_json()
assert body["ok"] is False
def test_payload_test_returns_ok_when_upstream_responds_2xx(
self, app, pg_conn
):
from application.api.user.models.routes import (
UserModelTestPayloadResource,
)
# pinned_post is the IP-pinned dispatch helper. Patching it
# bypasses both the SSRF guard and the network — the success
# path we're verifying here is the route's response handling.
with patch("application.api.user.models.routes.pinned_post") as rp:
rp.return_value = MagicMock(
status_code=200,
headers={"Content-Type": "application/json"},
text='{"ok": true}',
)
with app.test_request_context(
"/api/user/models/test",
method="POST",
json={
"base_url": "https://api.mistral.ai/v1",
"api_key": "sk-mistral-test",
"upstream_model_id": "mistral-large-latest",
},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelTestPayloadResource().post()
assert resp.status_code == 200
body = resp.get_json()
assert body["ok"] is True
# Verify the upstream call carried the user's submitted key (not
# whatever's in the DB) and the right model name.
call_args = rp.call_args
assert call_args.kwargs["headers"]["Authorization"] == "Bearer sk-mistral-test"
assert call_args.kwargs["json"]["model"] == "mistral-large-latest"
def test_payload_test_unauthenticated_returns_401(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelTestPayloadResource,
)
with app.test_request_context(
"/api/user/models/test",
method="POST",
json={
"base_url": "https://api.mistral.ai/v1",
"api_key": "k",
"upstream_model_id": "x",
},
):
from flask import request
request.decoded_token = None
with _patch_db(pg_conn):
resp = UserModelTestPayloadResource().post()
assert resp.status_code == 401
def test_payload_test_missing_fields_returns_400(self, app, pg_conn):
from application.api.user.models.routes import (
UserModelTestPayloadResource,
)
with app.test_request_context(
"/api/user/models/test",
method="POST",
json={"base_url": "https://api.mistral.ai/v1"},
):
from flask import request
request.decoded_token = {"sub": "user-1"}
with _patch_db(pg_conn):
resp = UserModelTestPayloadResource().post()
assert resp.status_code == 400
@pytest.mark.unit
class TestByIdConnectionTestAcceptsOverrides:
"""P3: in edit mode the modal sends current form state as overrides
so the test reflects in-flight edits (not the saved record)."""
def _make_row(self, pg_conn):
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
return UserCustomModelsRepository(pg_conn).create(
user_id="u",
upstream_model_id="stored-model",
display_name="Stored",
base_url="https://stored.example.com/v1",
api_key_plaintext="sk-stored",
)
def _post_test(self, app, pg_conn, model_id, body):
from application.api.user.models.routes import UserModelTestResource
with patch("application.api.user.models.routes.pinned_post") as rp:
rp.return_value = MagicMock(
status_code=200,
headers={"Content-Type": "application/json"},
text='{"ok": true}',
)
with app.test_request_context(
f"/api/user/models/{model_id}/test", method="POST", json=body
):
from flask import request
request.decoded_token = {"sub": "u"}
with _patch_db(pg_conn):
UserModelTestResource().post(model_id=model_id)
return rp.call_args
def test_overrides_win_when_supplied(self, app, pg_conn):
row = self._make_row(pg_conn)
ca = self._post_test(
app,
pg_conn,
row["id"],
{
"base_url": "https://new.example.com/v1",
"api_key": "sk-new",
"upstream_model_id": "new-model",
},
)
assert ca.args[0] == "https://new.example.com/v1/chat/completions"
assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-new"
assert ca.kwargs["json"]["model"] == "new-model"
def test_blank_overrides_fall_back_to_stored(self, app, pg_conn):
"""The classic edit-mode flow: user changed base_url, left
api_key blank — server uses the new URL but the stored key."""
row = self._make_row(pg_conn)
ca = self._post_test(
app,
pg_conn,
row["id"],
{
"base_url": "https://new.example.com/v1",
"api_key": "",
"upstream_model_id": "",
},
)
assert ca.args[0] == "https://new.example.com/v1/chat/completions"
# Stored key (decrypted) was used.
assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-stored"
# Stored upstream_model_id was used.
assert ca.kwargs["json"]["model"] == "stored-model"
def test_empty_body_uses_all_stored_values(self, app, pg_conn):
row = self._make_row(pg_conn)
ca = self._post_test(app, pg_conn, row["id"], {})
assert ca.args[0] == "https://stored.example.com/v1/chat/completions"
assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-stored"
assert ca.kwargs["json"]["model"] == "stored-model"
@pytest.mark.unit
class TestApiModelsListWithUser:
def test_includes_user_models_when_authenticated(self, app, pg_conn):
"""GET /api/models with auth should surface the user's BYOM
records alongside built-ins, each tagged with `source`."""
from application.api.user.models.routes import ModelsListResource
from application.core.model_registry import ModelRegistry
created = _create_via_repo(
pg_conn, user_id="user-1", display_name="My Mistral"
)
# Patch the *registry's* db_readonly so the per-user layer load
# uses the test connection.
@contextmanager
def _yield():
yield pg_conn
with patch(
"application.storage.db.session.db_readonly", _yield
):
ModelRegistry.reset()
with app.test_request_context("/api/models"):
from flask import request
request.decoded_token = {"sub": "user-1"}
resp = ModelsListResource().get()
assert resp.status_code == 200
body = resp.get_json()
ids = [m["id"] for m in body["models"]]
assert created["id"] in ids
# Source label tags it for the UI
user_entries = [m for m in body["models"] if m["id"] == created["id"]]
assert user_entries[0]["source"] == "user"
# Built-ins still present
assert any(m.get("source") == "builtin" for m in body["models"])
+166
View File
@@ -0,0 +1,166 @@
import pytest
from flask import Flask
@pytest.fixture
def app():
app = Flask(__name__)
return app
@pytest.mark.unit
class TestGetUserId:
pass
def test_returns_user_id_from_decoded_token(self, app):
from application.api.user.utils import get_user_id
with app.test_request_context():
from flask import request
request.decoded_token = {"sub": "user_123"}
assert get_user_id() == "user_123"
def test_returns_none_when_no_decoded_token(self, app):
from application.api.user.utils import get_user_id
with app.test_request_context():
assert get_user_id() is None
def test_returns_none_when_decoded_token_has_no_sub(self, app):
from application.api.user.utils import get_user_id
with app.test_request_context():
from flask import request
request.decoded_token = {}
assert get_user_id() is None
@pytest.mark.unit
class TestRequireAuth:
pass
def test_allows_authenticated_request(self, app):
from application.api.user.utils import require_auth
@require_auth
def protected():
return "ok"
with app.test_request_context():
from flask import request
request.decoded_token = {"sub": "user_123"}
assert protected() == "ok"
def test_returns_401_when_unauthenticated(self, app):
from application.api.user.utils import require_auth
@require_auth
def protected():
return "ok"
with app.test_request_context():
result = protected()
assert result.status_code == 401
@pytest.mark.unit
class TestSuccessResponse:
pass
def test_default_success_response(self, app):
from application.api.user.utils import success_response
with app.app_context():
resp = success_response()
assert resp.status_code == 200
assert resp.json["success"] is True
@pytest.mark.unit
class TestErrorResponse:
pass
def test_error_response_custom_status(self, app):
from application.api.user.utils import error_response
with app.app_context():
resp = error_response("Not found", 404)
assert resp.status_code == 404
def test_error_response_extra_kwargs(self, app):
from application.api.user.utils import error_response
with app.app_context():
resp = error_response("Bad", 400, errors=["field1", "field2"])
assert resp.json["errors"] == ["field1", "field2"]
@pytest.mark.unit
class TestValidateObjectId:
pass
@pytest.mark.unit
class TestValidatePagination:
pass
@pytest.mark.unit
class TestCheckResourceOwnership:
pass
@pytest.mark.unit
class TestSerializeObjectId:
pass
@pytest.mark.unit
class TestSerializeList:
pass
@pytest.mark.unit
class TestRequireFields:
pass
def test_allows_valid_request(self, app):
from application.api.user.utils import require_fields
@require_fields(["name", "email"])
def handler():
return "ok"
with app.test_request_context(
"/", method="POST", json={"name": "Alice", "email": "a@b.com"}
):
assert handler() == "ok"
def test_rejects_empty_body(self, app):
from application.api.user.utils import require_fields
@require_fields(["name"])
def handler():
return "ok"
with app.test_request_context(
"/", method="POST", json={}
):
result = handler()
assert result.status_code == 400
@pytest.mark.unit
class TestSafeDbOperation:
pass
@pytest.mark.unit
class TestValidateEnum:
pass
@pytest.mark.unit
class TestExtractSortParams:
pass
+20
View File
@@ -0,0 +1,20 @@
"""Additional tests for application/api/user/utils.py to cover paginated_response.
Target missing lines:
- 257-262: paginated_response (collection query + serializer + response)
"""
import pytest
from flask import Flask
@pytest.fixture
def app():
app = Flask(__name__)
return app
+12
View File
@@ -0,0 +1,12 @@
"""Tests for application/api/user/agents/webhooks.py.
Previously coupled to bson.ObjectId + patched agents_collection. Scheduled
for rewrite against pg_conn + AgentsRepository.
"""
import pytest
@pytest.mark.skip(reason="needs PG fixture rewrite - tracked separately")
def test_agent_webhooks_pending_pg_rewrite():
pass
+12
View File
@@ -0,0 +1,12 @@
"""Tests for application/api/user/workflows/routes.py.
Previously asserted on bson.ObjectId serialization. Workflow persistence is
now Postgres-backed; coverage will be rebuilt via pg_conn + WorkflowsRepository.
"""
import pytest
@pytest.mark.skip(reason="needs PG fixture rewrite - tracked separately")
def test_user_workflows_routes_pending_pg_rewrite():
pass
@@ -0,0 +1,785 @@
"""Additional coverage tests for application.api.user.workflows.routes.
No bson/ObjectId imports. Mongo collections are replaced by Mock objects.
``validate_object_id`` (which calls bson internally) is patched wherever
WorkflowDetail routes invoke it so that tests run without pymongo.
Repository classes (WorkflowsRepository, etc.) are patched for dual-write paths.
"""
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
from flask import Flask
@pytest.fixture
def app():
return Flask(__name__)
def _fake_oid():
"""24-character hex string that substitutes for a Mongo ObjectId string."""
return uuid.uuid4().hex[:24]
def _mock_validate_object_id(wf_id):
"""Return a patcher that makes validate_object_id return (wf_id, None)."""
mock_oid = Mock()
mock_oid.__str__ = lambda self: wf_id
return patch(
"application.api.user.workflows.routes.validate_object_id",
return_value=(mock_oid, None),
)
# ---------------------------------------------------------------------------
# Serializer helpers — pure functions, no DB
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSerializeWorkflowCoverage:
pass
@pytest.mark.unit
class TestSerializeNodeCoverage:
pass
@pytest.mark.unit
class TestSerializeEdgeCoverage:
pass
# ---------------------------------------------------------------------------
# get_workflow_graph_version — edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetWorkflowGraphVersionCoverage:
pass
def test_large_version_number(self):
from application.api.user.workflows.routes import get_workflow_graph_version
assert get_workflow_graph_version({"current_graph_version": 99}) == 99
def test_float_string_falls_back_to_1(self):
from application.api.user.workflows.routes import get_workflow_graph_version
# int("3.5") raises ValueError → falls back to 1
assert get_workflow_graph_version({"current_graph_version": "3.5"}) == 1
def test_none_value_returns_1(self):
from application.api.user.workflows.routes import get_workflow_graph_version
assert get_workflow_graph_version({"current_graph_version": None}) == 1
# ---------------------------------------------------------------------------
# fetch_graph_documents
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFetchGraphDocumentsCoverage:
pass
# ---------------------------------------------------------------------------
# validate_workflow_structure — additional condition-node coverage
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateWorkflowStructureCoverage:
pass
def test_valid_condition_node_with_two_outgoing_edges(self):
from application.api.user.workflows.routes import validate_workflow_structure
nodes = [
{"id": "start", "type": "start"},
{
"id": "cond",
"type": "condition",
"data": {
"cases": [{"expression": "x > 0", "sourceHandle": "case1"}]
},
},
{"id": "end1", "type": "end"},
{"id": "end2", "type": "end"},
]
edges = [
{"id": "e1", "source": "start", "target": "cond"},
{"id": "e2", "source": "cond", "target": "end1", "sourceHandle": "case1"},
{"id": "e3", "source": "cond", "target": "end2", "sourceHandle": "else"},
]
errors = validate_workflow_structure(nodes, edges)
assert errors == []
def test_multiple_end_nodes_allowed(self):
from application.api.user.workflows.routes import validate_workflow_structure
nodes = [
{"id": "start", "type": "start"},
{"id": "end1", "type": "end"},
{"id": "end2", "type": "end"},
]
edges = [
{"id": "e1", "source": "start", "target": "end1"},
]
errors = validate_workflow_structure(nodes, edges)
assert isinstance(errors, list)
# ---------------------------------------------------------------------------
# WorkflowList.post — create workflow (uuid-based IDs, no bson)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWorkflowListPostCoverage:
pass
def test_create_unauthorized_returns_401(self, app):
from application.api.user.workflows.routes import WorkflowList
with app.test_request_context(
"/api/workflows", method="POST", json={"name": "WF"}
):
from flask import request
request.decoded_token = None
response = WorkflowList().post()
assert response.status_code == 401
def test_create_missing_name_returns_400(self, app):
from application.api.user.workflows.routes import WorkflowList
with app.test_request_context(
"/api/workflows", method="POST", json={"description": "no name"}
):
from flask import request
request.decoded_token = {"sub": "user1"}
response = WorkflowList().post()
assert response.status_code == 400
# ---------------------------------------------------------------------------
# WorkflowDetail.get — retrieve a single workflow (mocked validate_object_id)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWorkflowDetailGetCoverage:
pass
def test_returns_401_unauthenticated(self, app):
from application.api.user.workflows.routes import WorkflowDetail
with app.test_request_context("/api/workflows/abc", method="GET"):
from flask import request
request.decoded_token = None
response = WorkflowDetail().get("abc")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# WorkflowDetail.delete — delete workflow (mocked validate_object_id)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWorkflowDetailDeleteCoverage:
pass
def test_delete_unauthorized(self, app):
from application.api.user.workflows.routes import WorkflowDetail
with app.test_request_context("/api/workflows/abc", method="DELETE"):
from flask import request
request.decoded_token = None
response = WorkflowDetail().delete("abc")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# Real-PG happy-path tests using pg_conn
# ---------------------------------------------------------------------------
@contextmanager
def _patch_wf_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.workflows.routes.db_session", _yield
), patch(
"application.api.user.workflows.routes.db_readonly", _yield
):
yield
def _minimal_workflow_body(name="WF1"):
return {
"name": name,
"description": "desc",
"nodes": [
{"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
],
"edges": [
{"id": "e1", "source": "start1", "target": "end1"},
],
}
class TestSerializers:
def test_serialize_workflow_fields(self):
from application.api.user.workflows.routes import serialize_workflow
import datetime
wf = {
"id": "00000000-0000-0000-0000-000000000001",
"user_id": "u1",
"name": "hello",
"description": "d",
"current_graph_version": 3,
"created_at": datetime.datetime(2024, 1, 1, 12, 0, 0),
"updated_at": datetime.datetime(2024, 1, 2, 12, 0, 0),
}
got = serialize_workflow(wf)
assert got["id"] == wf["id"]
assert got["name"] == "hello"
assert got["description"] == "d"
# created_at gets iso-formatted
assert got["created_at"] == "2024-01-01T12:00:00"
def test_serialize_node_shape(self):
from application.api.user.workflows.routes import serialize_node
node = {
"id": "00000000-0000-0000-0000-000000000002",
"node_id": "start1",
"node_type": "start",
"title": "Start",
"description": "",
"position": {"x": 0, "y": 0},
"config": {},
}
out = serialize_node(node)
assert out["id"] == "start1"
assert out["type"] == "start"
assert out["position"] == {"x": 0, "y": 0}
def test_serialize_edge_shape(self):
from application.api.user.workflows.routes import serialize_edge
edge = {
"id": "00000000-0000-0000-0000-000000000003",
"edge_id": "e-1",
"source_node_id": "start1",
"target_node_id": "end1",
"source_handle": None,
"target_handle": None,
"config": {},
}
out = serialize_edge(edge)
assert out["id"] == "e-1"
class TestWorkflowListPost:
def test_creates_valid_workflow(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowList
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows",
method="POST",
json=_minimal_workflow_body("first"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowList().post()
assert response.status_code == 200
body = response.get_json()
assert body["success"] is True
assert body["data"]["id"]
def test_create_validation_failure_returns_400(self, app, pg_conn):
"""Workflow with no start node should fail validation."""
from application.api.user.workflows.routes import WorkflowList
body = {
"name": "bad",
"nodes": [{"id": "end1", "type": "end"}],
"edges": [],
}
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows", method="POST", json=body
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowList().post()
assert response.status_code == 400
def test_create_db_error_returns_400(self, app):
from application.api.user.workflows.routes import WorkflowList
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.workflows.routes.db_session", _broken
), app.test_request_context(
"/api/workflows",
method="POST",
json=_minimal_workflow_body("x"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowList().post()
assert response.status_code == 400
class TestWorkflowDetailGet:
def test_returns_404_for_missing_workflow(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowDetail
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/00000000-0000-0000-0000-000000000000",
method="GET",
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().get(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_returns_workflow_after_create(self, app, pg_conn):
from application.api.user.workflows.routes import (
WorkflowDetail,
WorkflowList,
)
# Create one first
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows",
method="POST",
json=_minimal_workflow_body("retrievable"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
created = WorkflowList().post()
wf_id = created.get_json()["data"]["id"]
with _patch_wf_db(pg_conn), app.test_request_context(
f"/api/workflows/{wf_id}", method="GET"
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().get(wf_id)
assert response.status_code == 200
data = response.get_json()["data"]
assert data["workflow"]["name"] == "retrievable"
assert len(data["nodes"]) == 2
assert len(data["edges"]) == 1
def test_db_error_returns_400(self, app):
from application.api.user.workflows.routes import WorkflowDetail
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.workflows.routes.db_readonly", _broken
), app.test_request_context("/api/workflows/abc"):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().get("abc")
assert response.status_code == 400
class TestWorkflowDetailPut:
def test_returns_401_unauthenticated(self, app):
from application.api.user.workflows.routes import WorkflowDetail
with app.test_request_context(
"/api/workflows/abc",
method="PUT",
json={"name": "x"},
):
from flask import request
request.decoded_token = None
response = WorkflowDetail().put("abc")
assert response.status_code == 401
def test_returns_400_missing_name(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowDetail
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/abc",
method="PUT",
json={"description": "x"},
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put("abc")
assert response.status_code == 400
def test_returns_404_missing_workflow(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowDetail
body = _minimal_workflow_body("new")
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/00000000-0000-0000-0000-000000000000",
method="PUT",
json=body,
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_validation_failure_returns_400(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowDetail
body = {
"name": "bad",
"nodes": [{"id": "end1", "type": "end"}],
"edges": [],
}
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/abc",
method="PUT",
json=body,
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put("abc")
assert response.status_code == 400
def test_updates_workflow(self, app, pg_conn):
from application.api.user.workflows.routes import (
WorkflowDetail,
WorkflowList,
)
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows",
method="POST",
json=_minimal_workflow_body("before"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
created = WorkflowList().post()
wf_id = created.get_json()["data"]["id"]
body = _minimal_workflow_body("after")
with _patch_wf_db(pg_conn), app.test_request_context(
f"/api/workflows/{wf_id}",
method="PUT",
json=body,
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put(wf_id)
assert response.status_code == 200
# Verify the version bumped
with _patch_wf_db(pg_conn), app.test_request_context(
f"/api/workflows/{wf_id}", method="GET"
):
from flask import request
request.decoded_token = {"sub": "u1"}
got = WorkflowDetail().get(wf_id)
data = got.get_json()["data"]
assert data["workflow"]["name"] == "after"
def test_db_error_returns_400(self, app):
from application.api.user.workflows.routes import WorkflowDetail
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.workflows.routes.db_session", _broken
), app.test_request_context(
"/api/workflows/abc",
method="PUT",
json=_minimal_workflow_body("x"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put("abc")
assert response.status_code == 400
class TestWorkflowDetailDelete:
def test_returns_404_missing(self, app, pg_conn):
from application.api.user.workflows.routes import WorkflowDetail
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/00000000-0000-0000-0000-000000000000",
method="DELETE",
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().delete(
"00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
def test_deletes_workflow(self, app, pg_conn):
from application.api.user.workflows.routes import (
WorkflowDetail,
WorkflowList,
)
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows",
method="POST",
json=_minimal_workflow_body("tbd"),
):
from flask import request
request.decoded_token = {"sub": "u1"}
created = WorkflowList().post()
wf_id = created.get_json()["data"]["id"]
with _patch_wf_db(pg_conn), app.test_request_context(
f"/api/workflows/{wf_id}", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().delete(wf_id)
assert response.status_code == 200
def test_db_error_returns_400(self, app):
from application.api.user.workflows.routes import WorkflowDetail
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.user.workflows.routes.db_session", _broken
), app.test_request_context(
"/api/workflows/abc", method="DELETE"
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().delete("abc")
assert response.status_code == 400
class TestValidateWorkflowStructureExtras:
def test_no_nodes_returns_error(self):
from application.api.user.workflows.routes import (
validate_workflow_structure,
)
errors = validate_workflow_structure([], [])
assert any("at least one node" in e for e in errors)
def test_missing_start_node_returns_error(self):
from application.api.user.workflows.routes import (
validate_workflow_structure,
)
errors = validate_workflow_structure(
[{"id": "end1", "type": "end"}], []
)
assert any("start" in e for e in errors)
def test_missing_end_node_returns_error(self):
from application.api.user.workflows.routes import (
validate_workflow_structure,
)
errors = validate_workflow_structure(
[{"id": "s", "type": "start"}],
[{"id": "e1", "source": "s", "target": "s"}],
)
assert any("end" in e for e in errors)
def test_edge_with_missing_source_reports_error(self):
from application.api.user.workflows.routes import (
validate_workflow_structure,
)
errors = validate_workflow_structure(
[
{"id": "s", "type": "start"},
{"id": "e", "type": "end"},
],
[
{"id": "edge1", "source": "ghost", "target": "e"},
{"id": "edge2", "source": "s", "target": "e"},
],
)
assert any("non-existent source" in err for err in errors)
def test_condition_node_without_else_branch_errors(self):
from application.api.user.workflows.routes import (
validate_workflow_structure,
)
nodes = [
{"id": "start", "type": "start"},
{
"id": "cond",
"type": "condition",
"data": {"cases": [{"expression": "x", "sourceHandle": "yes"}]},
},
{"id": "end", "type": "end"},
]
edges = [
{"id": "e1", "source": "start", "target": "cond"},
{"id": "e2", "source": "cond", "target": "end", "sourceHandle": "yes"},
]
errors = validate_workflow_structure(nodes, edges)
assert any("'else'" in e for e in errors)
class TestValidateJsonSchemaPayload:
def test_none_returns_pair_of_none(self):
from application.api.user.workflows.routes import (
validate_json_schema_payload,
)
got, err = validate_json_schema_payload(None)
assert got is None and err is None
def test_valid_schema(self):
from application.api.user.workflows.routes import (
validate_json_schema_payload,
)
got, err = validate_json_schema_payload(
{"type": "object", "properties": {"a": {"type": "string"}}},
)
assert err is None
assert got is not None
def test_invalid_schema_returns_error(self):
from application.api.user.workflows.routes import (
validate_json_schema_payload,
)
# Force an invalid payload by passing something that isn't dict
got, err = validate_json_schema_payload("not-a-schema")
# Either returns error, or returns normalized; handle both
assert (got is None and isinstance(err, str)) or got is not None
class TestNormalizeAgentNodeJsonSchemas:
def test_returns_non_dict_entries_as_is(self):
from application.api.user.workflows.routes import (
normalize_agent_node_json_schemas,
)
got = normalize_agent_node_json_schemas(["not-a-dict"])
assert got == ["not-a-dict"]
def test_non_agent_node_passes_through(self):
from application.api.user.workflows.routes import (
normalize_agent_node_json_schemas,
)
got = normalize_agent_node_json_schemas(
[{"id": "s", "type": "start"}]
)
assert got[0]["type"] == "start"
def test_agent_node_without_json_schema_passes_through(self):
from application.api.user.workflows.routes import (
normalize_agent_node_json_schemas,
)
got = normalize_agent_node_json_schemas(
[{"id": "a", "type": "agent", "data": {"other": 1}}]
)
assert got[0]["data"]["other"] == 1
def test_agent_node_with_schema_normalizes(self):
from application.api.user.workflows.routes import (
normalize_agent_node_json_schemas,
)
got = normalize_agent_node_json_schemas([
{
"id": "a",
"type": "agent",
"data": {"json_schema": {"type": "object"}},
}
])
assert got[0]["data"]["json_schema"] is not None
def test_agent_node_invalid_schema_kept_original(self):
from application.api.user.workflows.routes import (
normalize_agent_node_json_schemas,
)
got = normalize_agent_node_json_schemas([
{
"id": "a",
"type": "agent",
"data": {"json_schema": "not-a-dict"},
}
])
# Should still return something
assert got[0]["type"] == "agent"
class TestWriteGraphEdgesWithUnresolvedNodes:
def test_drops_edge_with_unknown_source(self, pg_conn, app):
from application.api.user.workflows.routes import (
_write_graph,
)
from application.storage.db.repositories.workflows import (
WorkflowsRepository,
)
user = "u-unresolved"
wf = WorkflowsRepository(pg_conn).create(user, "wf")
pg_wf_id = str(wf["id"])
nodes_data = [
{"id": "n1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "n2", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
]
edges_data = [
{"id": "e1", "source": "n1", "target": "n2"},
# Unresolved node reference
{"id": "e2", "source": "ghost", "target": "n2"},
]
with app.app_context():
_write_graph(pg_conn, pg_wf_id, 1, nodes_data, edges_data)
def test_get_workflow_graph_version_negative_falls_back(self):
from application.api.user.workflows.routes import (
get_workflow_graph_version,
)
assert get_workflow_graph_version({"current_graph_version": -5}) == 1