FastAPI app, upstream Confluence/model adapters, authoritative history, rootless container lifecycle, artifact storage and downloads, fake peers under backend/dev, tests under tests/backend. Root pytest.ini deselects the live marker by default; requirements gain the backend dependencies.
180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
"""Unit tests for Confluence client and dispatcher."""
|
|
|
|
import json
|
|
import pytest
|
|
import httpx
|
|
from backend.confluence import ConfluenceClient, ConfluenceDispatcher, escape_cql_literal
|
|
from backend.errors import (
|
|
ConfluenceAuthFailedError,
|
|
DestinationDeniedError,
|
|
InvalidInputError,
|
|
UpstreamResponseTooLargeError,
|
|
)
|
|
|
|
|
|
def test_escape_cql_literal():
|
|
assert escape_cql_literal('deploy "service" X') == 'deploy \\"service\\" X'
|
|
assert escape_cql_literal("path\\to\\file") == "path\\\\to\\\\file"
|
|
assert escape_cql_literal("line1\nline2") == "line1 line2"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confluence_auth_verify_success():
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.headers.get("Authorization") == "Bearer test-pat"
|
|
assert request.url.path == "/rest/api/space"
|
|
return httpx.Response(200, json={"results": [{"key": "ENG"}]}, headers={"content-type": "application/json"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = ConfluenceClient(
|
|
base_url="https://approved.example.com",
|
|
pat="test-pat",
|
|
approved_origins=["https://approved.example.com"],
|
|
transport=transport,
|
|
)
|
|
|
|
assert await client.verify_auth() is True
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confluence_auth_verify_fail():
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401, json={"message": "Unauthorized"}, headers={"content-type": "application/json"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = ConfluenceClient(
|
|
base_url="https://approved.example.com",
|
|
pat="invalid-pat",
|
|
approved_origins=["https://approved.example.com"],
|
|
transport=transport,
|
|
)
|
|
|
|
with pytest.raises(ConfluenceAuthFailedError):
|
|
await client.verify_auth()
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confluence_redirect_denied():
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(302, headers={"Location": "https://other.example.com/login"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = ConfluenceClient(
|
|
base_url="https://approved.example.com",
|
|
pat="test-pat",
|
|
approved_origins=["https://approved.example.com"],
|
|
transport=transport,
|
|
)
|
|
|
|
with pytest.raises(DestinationDeniedError):
|
|
await client.verify_auth()
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confluence_search_and_dispatch():
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path == "/rest/api/content/search":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"results": [
|
|
{
|
|
"id": "847291",
|
|
"title": "Deployment Guide",
|
|
"space": {"key": "OPS"},
|
|
"excerpt": "Deployment steps",
|
|
}
|
|
],
|
|
"totalSize": 1,
|
|
},
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
elif request.url.path == "/rest/api/content/847291":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "847291",
|
|
"title": "Deployment Guide",
|
|
"space": {"key": "OPS"},
|
|
"body": {"storage": {"value": "<p>Deploy service X</p>"}},
|
|
},
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
return httpx.Response(404)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = ConfluenceClient(
|
|
base_url="https://approved.example.com",
|
|
pat="test-pat",
|
|
approved_origins=["https://approved.example.com"],
|
|
transport=transport,
|
|
)
|
|
|
|
dispatcher = ConfluenceDispatcher(client, max_calls=5)
|
|
|
|
# 1. Search call
|
|
result, error, cache_hit = await dispatcher.dispatch(
|
|
"confluence_search", {"query": "deploy service X", "limit": 10}
|
|
)
|
|
assert error is None
|
|
assert cache_hit is False
|
|
assert len(result["pages"]) == 1
|
|
assert result["pages"][0]["page_id"] == "847291"
|
|
assert result["pages"][0]["url"] == "https://approved.example.com/pages/viewpage.action?pageId=847291"
|
|
assert result["pagination"]["has_more"] is False
|
|
|
|
# 2. Repeated search call -> cache hit
|
|
result2, error2, cache_hit2 = await dispatcher.dispatch(
|
|
"confluence_search", {"query": "deploy service X", "limit": 10}
|
|
)
|
|
assert cache_hit2 is True
|
|
assert result2 == result
|
|
|
|
# 3. View page call
|
|
v_res, v_err, v_hit = await dispatcher.dispatch("confluence_view", {"page_id": "847291"})
|
|
assert v_err is None
|
|
assert v_hit is False
|
|
assert v_res["page_id"] == "847291"
|
|
assert "Deploy service X" in v_res["markdown"]
|
|
assert v_res["truncated"] is False
|
|
|
|
# 4. Repeated view -> cache hit
|
|
v_res2, v_err2, v_hit2 = await dispatcher.dispatch("confluence_view", {"page_id": "847291"})
|
|
assert v_hit2 is True
|
|
assert v_res2 == v_res
|
|
|
|
# 5. Invalid page id -> error
|
|
inv_res, inv_err, _ = await dispatcher.dispatch("confluence_view", {"page_id": "invalid-id"})
|
|
assert inv_err is not None
|
|
assert inv_err["code"] == "invalid_input"
|
|
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confluence_call_budget():
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"results": []}, headers={"content-type": "application/json"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
client = ConfluenceClient(
|
|
base_url="https://approved.example.com",
|
|
pat="test-pat",
|
|
approved_origins=["https://approved.example.com"],
|
|
transport=transport,
|
|
)
|
|
|
|
dispatcher = ConfluenceDispatcher(client, max_calls=2)
|
|
|
|
await dispatcher.dispatch("confluence_search", {"query": "q1"})
|
|
await dispatcher.dispatch("confluence_search", {"query": "q2"})
|
|
|
|
# 3rd call should fail budget
|
|
_, err, _ = await dispatcher.dispatch("confluence_search", {"query": "q3"})
|
|
assert err is not None
|
|
assert "limit" in err["message"]
|
|
await client.close()
|