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
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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user