Files
resolutionflow/backend/tests/test_kb_accelerator.py
Michael Chihlas 97d36dd400 test(kb-accelerator): downgrade kb_setup user to free plan
The kb_setup fixture asserts free-plan quota numbers (lifetime_conversions_limit=3),
but Phase 1 conftest seeds test_user on Pro. Downgrade explicitly inside kb_setup
to preserve the original test intent without affecting other suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00

371 lines
13 KiB
Python

"""Integration tests for KB Accelerator endpoints."""
import pytest
import json
from unittest.mock import AsyncMock, patch, PropertyMock
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── Fixtures ──
@pytest.fixture
async def kb_setup(client, auth_headers, test_db):
"""Seed KB plan limits and return helpers."""
# KB tests were authored against a free-plan user. Phase 1 conftest seeds
# the test_user with a pro/active Subscription; downgrade to free here so
# quota numbers match the original test intent.
from app.models.subscription import Subscription
sub = (await test_db.execute(__import__("sqlalchemy").select(Subscription))).scalar_one()
sub.plan = "free"
await test_db.commit()
# Update plan_limits with KB columns for 'free' plan
await test_db.execute(
__import__("sqlalchemy").text("""
UPDATE plan_limits SET
kb_accelerator_enabled = true,
kb_max_lifetime_conversions = 3,
kb_allowed_formats = '["txt","paste"]'::jsonb,
kb_detailed_analysis = false,
kb_conversational_refinement = false,
kb_step_library_matching = false,
kb_history_limit = 3
WHERE plan = 'free'
""")
)
await test_db.execute(
__import__("sqlalchemy").text("""
UPDATE plan_limits SET
kb_accelerator_enabled = true,
kb_max_lifetime_conversions = NULL,
kb_allowed_formats = '["txt","paste","docx","pdf","html","md"]'::jsonb,
kb_detailed_analysis = true,
kb_conversational_refinement = true,
kb_step_library_matching = true,
kb_history_limit = NULL
WHERE plan = 'pro'
""")
)
await test_db.commit()
return {"client": client, "headers": auth_headers}
def _mock_ai_enabled():
"""Context manager to mock AI as enabled."""
return patch.object(
type(__import__("app.core.config", fromlist=["settings"]).settings),
"ai_enabled",
new_callable=PropertyMock,
return_value=True,
)
SAMPLE_KB_TEXT = """
Troubleshooting Outlook Connectivity Issues
Problem: Users report that Outlook keeps disconnecting from Exchange.
Step 1: Check Network Connectivity
Ping the Exchange server to verify network connectivity.
If ping fails, check the network configuration.
Step 2: Verify Outlook Profile
If the network is working, check the Outlook profile settings.
Go to Control Panel > Mail > Show Profiles.
Step 3: Check Exchange Server
If the profile is correct, verify the Exchange server is running.
Open Services.msc and check Microsoft Exchange services.
Resolution: After following these steps, Outlook should maintain
a persistent connection to Exchange.
"""
MOCK_AI_TROUBLESHOOTING_RESPONSE = json.dumps({
"title": "Troubleshooting Outlook Connectivity",
"description": "Diagnose and fix Outlook disconnection from Exchange",
"nodes": [
{
"id": "root-check",
"type": "question",
"question": "Is the network connection working?",
"options": [
{"label": "Yes", "next_node_id": "check-profile"},
{"label": "No", "next_node_id": "fix-network"},
],
"confidence": 0.92,
"source_excerpt": "Step 1: Check Network Connectivity",
},
{
"id": "fix-network",
"type": "resolution",
"question": "Fix the network configuration and retry.",
"confidence": 0.85,
"source_excerpt": "If ping fails, check the network configuration.",
},
{
"id": "check-profile",
"type": "question",
"question": "Is the Outlook profile configured correctly?",
"options": [
{"label": "Yes", "next_node_id": "check-exchange"},
{"label": "No", "next_node_id": "fix-profile"},
],
"confidence": 0.88,
"source_excerpt": "Step 2: Verify Outlook Profile",
},
{
"id": "fix-profile",
"type": "resolution",
"question": "Reconfigure the Outlook profile via Control Panel > Mail.",
"confidence": 0.90,
"source_excerpt": "Go to Control Panel > Mail > Show Profiles.",
},
{
"id": "check-exchange",
"type": "resolution",
"question": "Verify Exchange services are running in Services.msc.",
"confidence": 0.87,
"source_excerpt": "Open Services.msc and check Microsoft Exchange services.",
},
],
})
MOCK_AI_PROCEDURAL_RESPONSE = json.dumps({
"title": "Setup New Domain Controller",
"description": "Step-by-step procedure for setting up a new DC",
"steps": [
{
"id": "step-1",
"type": "step",
"content": "Open Server Manager on [VAR:server_name]",
"confidence": 0.95,
"source_excerpt": "Step 1: Open Server Manager on DC01",
},
{
"id": "warning-dns",
"type": "warning",
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
"confidence": 0.90,
"source_excerpt": "Note: Restarting DNS will cause a brief outage",
},
{
"id": "step-2",
"type": "step",
"content": "Configure IP address [VAR:ip_address] on the network adapter",
"confidence": 0.88,
"source_excerpt": "Configure IP 192.168.1.10 on the adapter",
},
],
"intake_form": [
{
"variable_name": "server_name",
"label": "Server Name",
"field_type": "text",
"required": True,
"display_order": 1,
},
{
"variable_name": "ip_address",
"label": "IP Address",
"field_type": "text",
"required": True,
"display_order": 2,
},
],
})
# ── Upload Tests ──
class TestUpload:
async def test_upload_text_paste(self, kb_setup):
"""Upload via text paste creates a kb_import in processing status."""
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled():
# Mock the background conversion (don't actually call AI)
with patch("app.api.endpoints.kb_accelerator._run_conversion"):
resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
headers=h,
)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "processing"
assert data["source_format"] == "paste"
assert "id" in data
async def test_upload_empty_content_rejected(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled():
resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": "short"},
headers=h,
)
assert resp.status_code == 400
async def test_upload_no_file_no_content_rejected(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled():
resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={},
headers=h,
)
assert resp.status_code == 400
# ── Get/List Tests ──
class TestGetList:
async def test_get_import(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
create_resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
headers=h,
)
import_id = create_resp.json()["id"]
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == import_id
assert data["source_format"] == "paste"
async def test_list_imports(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
headers=h,
)
resp = await c.get("/api/v1/kb-accelerator", headers=h)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert len(data["items"]) >= 1
# ── Quota Tests ──
class TestQuota:
async def test_get_quota(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
resp = await c.get("/api/v1/kb-accelerator/quota", headers=h)
assert resp.status_code == 200
data = resp.json()
assert data["kb_accelerator_enabled"] is True
assert data["lifetime_conversions_limit"] == 3
assert data["can_convert"] is True
# ── Commit Tests ──
class TestCommit:
async def test_commit_creates_tree(self, kb_setup, test_db):
"""Committing a ready import creates a Tree record."""
c, h = kb_setup["client"], kb_setup["headers"]
# Create import
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
create_resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
headers=h,
)
import_id = create_resp.json()["id"]
# Simulate conversion complete: update status + add nodes directly
from app.models.kb_import import KBImport, KBImportNode
from sqlalchemy import select
import uuid
result = await test_db.execute(select(KBImport).where(KBImport.id == uuid.UUID(import_id)))
kb_import = result.scalar_one()
kb_import.status = "ready"
kb_import.source_metadata = {"_conversion": {"title": "Test Flow", "description": "Test"}}
# Build a valid tree: root decision with 2 branches leading to solutions
nodes_data = [
KBImportNode(
kb_import_id=kb_import.id, node_order=0, node_type="question",
content={
"original_id": "root", "question": "What is the issue?",
"options": [
{"id": "opt-root-0", "label": "Option A", "next_node_id": "action-a"},
{"id": "opt-root-1", "label": "Option B", "next_node_id": "action-b"},
],
},
confidence_score=0.9,
),
KBImportNode(
kb_import_id=kb_import.id, node_order=1, node_type="action",
content={"original_id": "action-a", "question": "Try fix A", "description": "Do thing A", "next_node_id": "solution-a"},
confidence_score=0.9,
),
KBImportNode(
kb_import_id=kb_import.id, node_order=2, node_type="action",
content={"original_id": "action-b", "question": "Try fix B", "description": "Do thing B", "next_node_id": "solution-b"},
confidence_score=0.9,
),
KBImportNode(
kb_import_id=kb_import.id, node_order=3, node_type="resolution",
content={"original_id": "solution-a", "question": "Resolved via A", "description": "Issue fixed by A"},
confidence_score=0.9,
),
KBImportNode(
kb_import_id=kb_import.id, node_order=4, node_type="resolution",
content={"original_id": "solution-b", "question": "Resolved via B", "description": "Issue fixed by B"},
confidence_score=0.9,
),
]
for n in nodes_data:
test_db.add(n)
await test_db.commit()
# Commit
resp = await c.post(f"/api/v1/kb-accelerator/{import_id}/commit", headers=h)
assert resp.status_code == 200
data = resp.json()
assert "tree_id" in data
assert data["tree_type"] == "troubleshooting"
# ── Delete Tests ──
class TestDelete:
async def test_delete_import(self, kb_setup):
c, h = kb_setup["client"], kb_setup["headers"]
with _mock_ai_enabled(), patch("app.api.endpoints.kb_accelerator._run_conversion"):
create_resp = await c.post(
"/api/v1/kb-accelerator/upload",
data={"content": SAMPLE_KB_TEXT, "target_type": "troubleshooting"},
headers=h,
)
import_id = create_resp.json()["id"]
resp = await c.delete(f"/api/v1/kb-accelerator/{import_id}", headers=h)
assert resp.status_code == 204
# Verify deleted
resp = await c.get(f"/api/v1/kb-accelerator/{import_id}", headers=h)
assert resp.status_code == 404