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
|
||||
Reference in New Issue
Block a user