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>
371 lines
13 KiB
Python
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
|