confluence_web/tests/backend/test_confluence.py
Artur Mukhamadiev e65fbf4b67 backend: FastAPI backend track handoff (contract revision 1)
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.
2026-09-14 21:57:54 +03:00

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()