1214 lines
44 KiB
Python
1214 lines
44 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
from app.database import Base, get_db
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from app.models.user import User
|
||
|
|
from app.models.role import Role
|
||
|
|
from app.models.permission import Permission, role_permissions
|
||
|
|
from app.models.knowledge import Knowledge, SourceType
|
||
|
|
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
|
||
|
|
from app.models.knowledge_to_agent import KnowledgeToAgent
|
||
|
|
from app.models.agent import Agent, AgentType
|
||
|
|
from uuid import UUID, uuid4
|
||
|
|
from app.api import knowledge as knowledge_router
|
||
|
|
from app.core.auth import get_current_user, require_permissions
|
||
|
|
from app.main import app
|
||
|
|
from app.core.config import settings
|
||
|
|
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
import io
|
||
|
|
import os
|
||
|
|
from unittest.mock import PropertyMock, patch
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
|
||
|
|
# Set enterprise flag to False
|
||
|
|
knowledge_router.HAS_ENTERPRISE = False
|
||
|
|
|
||
|
|
# Create a test FastAPI app
|
||
|
|
app = FastAPI()
|
||
|
|
app.include_router(
|
||
|
|
knowledge_router.router,
|
||
|
|
prefix=f"{settings.API_V1_STR}/knowledge",
|
||
|
|
tags=["knowledge"]
|
||
|
|
)
|
||
|
|
|
||
|
|
@pytest.fixture(scope="function")
|
||
|
|
def db():
|
||
|
|
"""Create a fresh database for each test."""
|
||
|
|
Base.metadata.create_all(bind=engine)
|
||
|
|
db = TestingSessionLocal()
|
||
|
|
try:
|
||
|
|
yield db
|
||
|
|
finally:
|
||
|
|
db.close()
|
||
|
|
Base.metadata.drop_all(bind=engine)
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_permissions(db) -> list[Permission]:
|
||
|
|
"""Create test permissions"""
|
||
|
|
permissions = []
|
||
|
|
for name in ["manage_knowledge"]:
|
||
|
|
perm = Permission(
|
||
|
|
name=name,
|
||
|
|
description=f"Can {name}"
|
||
|
|
)
|
||
|
|
db.add(perm)
|
||
|
|
permissions.append(perm)
|
||
|
|
db.commit()
|
||
|
|
for p in permissions:
|
||
|
|
db.refresh(p)
|
||
|
|
return permissions
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_role(db, test_organization, test_permissions) -> Role:
|
||
|
|
"""Create a test role with required permissions"""
|
||
|
|
role = Role(
|
||
|
|
id=1,
|
||
|
|
name="Test Role",
|
||
|
|
organization_id=test_organization.id
|
||
|
|
)
|
||
|
|
db.add(role)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
# Associate permissions with role
|
||
|
|
for perm in test_permissions:
|
||
|
|
db.execute(
|
||
|
|
role_permissions.insert().values(
|
||
|
|
role_id=role.id,
|
||
|
|
permission_id=perm.id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(role)
|
||
|
|
return role
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_user(db: Session, test_organization, test_role: Role) -> User:
|
||
|
|
"""Create a test user with required permissions"""
|
||
|
|
user = User(
|
||
|
|
id=uuid4(),
|
||
|
|
email="test@test.com",
|
||
|
|
hashed_password="hashed_password",
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
role_id=test_role.id,
|
||
|
|
is_active=True,
|
||
|
|
full_name="Test User"
|
||
|
|
)
|
||
|
|
db.add(user)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(user)
|
||
|
|
return user
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_agent(db: Session, test_organization) -> Agent:
|
||
|
|
"""Create a test agent"""
|
||
|
|
agent = Agent(
|
||
|
|
id=uuid4(),
|
||
|
|
name="Test Agent",
|
||
|
|
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||
|
|
instructions=["Test instruction"],
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
is_active=True
|
||
|
|
)
|
||
|
|
db.add(agent)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(agent)
|
||
|
|
return agent
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_knowledge(db: Session, test_organization) -> Knowledge:
|
||
|
|
"""Create a test knowledge entry"""
|
||
|
|
knowledge = Knowledge(
|
||
|
|
source="test.pdf",
|
||
|
|
source_type=SourceType.FILE,
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
created_at=datetime.now(timezone.utc)
|
||
|
|
)
|
||
|
|
db.add(knowledge)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(knowledge)
|
||
|
|
return knowledge
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_knowledge_queue(db: Session, test_organization, test_user) -> KnowledgeQueue:
|
||
|
|
"""Create a test knowledge queue entry"""
|
||
|
|
queue = KnowledgeQueue(
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
user_id=test_user.id,
|
||
|
|
source_type="pdf_file",
|
||
|
|
source="test.pdf",
|
||
|
|
status=QueueStatus.PENDING,
|
||
|
|
created_at=datetime.now(timezone.utc)
|
||
|
|
)
|
||
|
|
db.add(queue)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(queue)
|
||
|
|
return queue
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def client(test_user: User) -> TestClient:
|
||
|
|
"""Create test client with mocked dependencies"""
|
||
|
|
async def override_get_current_user():
|
||
|
|
return test_user
|
||
|
|
|
||
|
|
async def override_require_permissions(*args, **kwargs):
|
||
|
|
return test_user
|
||
|
|
|
||
|
|
def override_get_db():
|
||
|
|
try:
|
||
|
|
db = TestingSessionLocal()
|
||
|
|
yield db
|
||
|
|
finally:
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
app.dependency_overrides[get_current_user] = override_get_current_user
|
||
|
|
app.dependency_overrides[require_permissions] = lambda x: override_require_permissions
|
||
|
|
app.dependency_overrides[get_db] = override_get_db
|
||
|
|
|
||
|
|
return TestClient(app)
|
||
|
|
|
||
|
|
def test_upload_pdf(client: TestClient, test_organization):
|
||
|
|
"""Test uploading PDF files"""
|
||
|
|
# Create a test PDF file
|
||
|
|
pdf_content = b"%PDF-1.4 test pdf content"
|
||
|
|
files = [
|
||
|
|
("files", ("test.pdf", io.BytesIO(pdf_content), "application/pdf"))
|
||
|
|
]
|
||
|
|
|
||
|
|
# Create temp directory if it doesn't exist
|
||
|
|
os.makedirs("temp", exist_ok=True)
|
||
|
|
|
||
|
|
# Mock S3 configuration and upload
|
||
|
|
mock_s3_client = MagicMock()
|
||
|
|
mock_s3_client.put_object.return_value = {'ResponseMetadata': {'HTTPStatusCode': 200}}
|
||
|
|
mock_s3_client.generate_presigned_url.return_value = "https://test-bucket.s3.amazonaws.com/test.pdf"
|
||
|
|
|
||
|
|
with patch('app.core.config.settings.S3_FILE_STORAGE', True), \
|
||
|
|
patch('app.core.s3.upload_file_to_s3') as mock_upload, \
|
||
|
|
patch('app.core.s3.get_s3_signed_url') as mock_signed_url, \
|
||
|
|
patch('app.core.s3.get_s3_client', return_value=mock_s3_client):
|
||
|
|
# Configure mocks
|
||
|
|
mock_upload.return_value = "s3://test-bucket/test.pdf"
|
||
|
|
mock_signed_url.return_value = "https://test-bucket.s3.amazonaws.com/test.pdf"
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/upload/pdf",
|
||
|
|
files=files,
|
||
|
|
data={
|
||
|
|
"org_id": str(test_organization.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert "queue_items" in response.json()
|
||
|
|
assert len(response.json()["queue_items"]) == 1
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def dns(monkeypatch):
|
||
|
|
"""No real lookups from tests: DNS "can't tell" unless a test says so."""
|
||
|
|
answers = {}
|
||
|
|
|
||
|
|
async def resolves(host):
|
||
|
|
return answers.get(host)
|
||
|
|
monkeypatch.setattr(knowledge_router, "domain_resolves", resolves)
|
||
|
|
return answers
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_urls_fails_a_domain_that_does_not_exist_at_once(client: TestClient, test_organization, dns, db):
|
||
|
|
dns["teeckbox.com"] = False
|
||
|
|
dns["example.com"] = True
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://teeckbox.com", "https://example.com"],
|
||
|
|
})
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert [item["status"] for item in response.json()["queue_items"]] == ["failed", "pending"]
|
||
|
|
failed = db.query(KnowledgeQueue).filter(KnowledgeQueue.source == "https://teeckbox.com").one()
|
||
|
|
# The same words the crawler would have stored, so the reason reads the same
|
||
|
|
assert knowledge_router._serialize_queue_item(failed)["reason"].startswith("This site could not be found")
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_urls(client: TestClient, test_organization):
|
||
|
|
"""Test adding URLs for processing"""
|
||
|
|
urls_data = {
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"pdf_urls": ["https://example.com/test.pdf"],
|
||
|
|
"websites": ["https://example.com"]
|
||
|
|
}
|
||
|
|
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json=urls_data)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "queue_items" in data
|
||
|
|
assert len(data["queue_items"]) == 2 # One for PDF, one for website
|
||
|
|
assert all(item["status"] == "pending" for item in data["queue_items"])
|
||
|
|
|
||
|
|
def test_add_urls_counts_queued_crawls_against_the_plan(
|
||
|
|
client: TestClient, test_organization, test_user, db
|
||
|
|
):
|
||
|
|
"""A crawl holds its plan slot from the moment it is queued. Before this,
|
||
|
|
every request read a count of zero until the queue drained, so a one-source
|
||
|
|
plan could be filled several times over in one sitting."""
|
||
|
|
db.add(KnowledgeQueue(
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
user_id=test_user.id,
|
||
|
|
source_type="website",
|
||
|
|
source="https://already-queued.example",
|
||
|
|
status=QueueStatus.PENDING,
|
||
|
|
))
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
plan = MagicMock()
|
||
|
|
plan.max_knowledge_sources = 1
|
||
|
|
plan.max_sub_pages = 10
|
||
|
|
subscription = MagicMock()
|
||
|
|
subscription.plan = plan
|
||
|
|
|
||
|
|
with patch.object(knowledge_router, "HAS_ENTERPRISE", True), \
|
||
|
|
patch.object(knowledge_router, "require_accessible_subscription",
|
||
|
|
return_value=subscription, create=True):
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"pdf_urls": [],
|
||
|
|
"websites": ["https://second.example"],
|
||
|
|
})
|
||
|
|
|
||
|
|
assert response.status_code == 403
|
||
|
|
assert "Maximum number of knowledge sources (1)" in response.json()["detail"]
|
||
|
|
# and nothing new was queued
|
||
|
|
assert db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://second.example"
|
||
|
|
).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_sitemap(client: TestClient, test_organization, db):
|
||
|
|
"""A sitemaps request enqueues one source_type='sitemap' queue item."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"sitemaps": ["https://example.com/sitemap.xml"],
|
||
|
|
})
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert len(response.json()["queue_items"]) == 1
|
||
|
|
item = db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://example.com/sitemap.xml"
|
||
|
|
).first()
|
||
|
|
assert item is not None
|
||
|
|
assert item.source_type == "sitemap"
|
||
|
|
# Non-enterprise honors the configurable KB_MAX_LINKS (not a hardcoded default).
|
||
|
|
assert item.queue_metadata.get("max_links") == settings.KB_MAX_LINKS
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_website_honors_kb_max_links(client: TestClient, test_organization, db):
|
||
|
|
"""A self-hosted (non-enterprise) website crawl records KB_MAX_LINKS, not a
|
||
|
|
hardcoded 10, so the setting actually controls crawl scope (issue #237)."""
|
||
|
|
with patch("app.core.config.settings.KB_MAX_LINKS", 200):
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://example.com/docs"],
|
||
|
|
})
|
||
|
|
assert response.status_code == 200
|
||
|
|
item = db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://example.com/docs"
|
||
|
|
).first()
|
||
|
|
assert item is not None
|
||
|
|
assert item.queue_metadata.get("max_links") == 200
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_website_records_crawl_scope(client: TestClient, test_organization, db):
|
||
|
|
"""The chosen crawl scope reaches the crawler via the queue item; omitting it
|
||
|
|
leaves the crawler's default (the seed's own host) in charge."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://help.example.com/hc/en"],
|
||
|
|
"crawl_scope": "path",
|
||
|
|
})
|
||
|
|
assert response.status_code == 200
|
||
|
|
item = db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://help.example.com/hc/en"
|
||
|
|
).first()
|
||
|
|
assert item.queue_metadata.get("crawl_scope") == "path"
|
||
|
|
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://help.example.com/other"],
|
||
|
|
})
|
||
|
|
assert response.status_code == 200
|
||
|
|
item = db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://help.example.com/other"
|
||
|
|
).first()
|
||
|
|
assert item.queue_metadata.get("crawl_scope") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_website_rejects_unknown_crawl_scope(client: TestClient, test_organization):
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://example.com"],
|
||
|
|
"crawl_scope": "everything",
|
||
|
|
})
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
def test_link_knowledge_to_agent(client: TestClient, test_knowledge, test_agent):
|
||
|
|
"""Test linking knowledge to an agent"""
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/link",
|
||
|
|
params={
|
||
|
|
"knowledge_id": test_knowledge.id,
|
||
|
|
"agent_id": str(test_agent.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["message"] == "Knowledge linked to agent successfully"
|
||
|
|
|
||
|
|
def _vector_backed(knowledge, db):
|
||
|
|
"""Give a knowledge row the schema/table_name that triggers a vector sync."""
|
||
|
|
knowledge.schema = "ai"
|
||
|
|
knowledge.table_name = f"d_{uuid4()}"
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
def test_link_syncs_the_vector_store(client: TestClient, test_knowledge, test_agent, db):
|
||
|
|
"""A link is only real once the agent id reaches the vector filters.
|
||
|
|
|
||
|
|
Retrieval matches on filters->'agent_id'; a link row alone is invisible to
|
||
|
|
the agent, so the sync is part of the contract, not a side effect.
|
||
|
|
"""
|
||
|
|
_vector_backed(test_knowledge, db)
|
||
|
|
|
||
|
|
with patch("app.api.knowledge.knowledge_vector_links.add_agent") as add_agent:
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/link",
|
||
|
|
params={"knowledge_id": test_knowledge.id, "agent_id": str(test_agent.id)},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
add_agent.assert_called_once()
|
||
|
|
assert add_agent.call_args.args[2] == test_agent.id
|
||
|
|
|
||
|
|
|
||
|
|
def test_link_rolls_back_when_the_vector_sync_fails(
|
||
|
|
client: TestClient, test_knowledge, test_agent, db
|
||
|
|
):
|
||
|
|
"""Regression (prod, 2026-07-30): the link row was committed before the
|
||
|
|
vector update ran, so a failing update left the source shown as linked in
|
||
|
|
the dashboard while the agent could not retrieve any of it. The 500 the user
|
||
|
|
dismissed was the only hint. Both must land together or not at all.
|
||
|
|
"""
|
||
|
|
_vector_backed(test_knowledge, db)
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"app.api.knowledge.knowledge_vector_links.add_agent",
|
||
|
|
side_effect=Exception("vector store unavailable"),
|
||
|
|
):
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/link",
|
||
|
|
params={"knowledge_id": test_knowledge.id, "agent_id": str(test_agent.id)},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 500
|
||
|
|
orphan = (
|
||
|
|
db.query(KnowledgeToAgent)
|
||
|
|
.filter(
|
||
|
|
KnowledgeToAgent.knowledge_id == test_knowledge.id,
|
||
|
|
KnowledgeToAgent.agent_id == test_agent.id,
|
||
|
|
)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
assert orphan is None, "link row survived a failed vector sync"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unlink_syncs_the_vector_store(client: TestClient, test_knowledge, test_agent, db):
|
||
|
|
_vector_backed(test_knowledge, db)
|
||
|
|
db.add(KnowledgeToAgent(knowledge_id=test_knowledge.id, agent_id=test_agent.id))
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
with patch("app.api.knowledge.knowledge_vector_links.remove_agent") as remove_agent:
|
||
|
|
response = client.delete(
|
||
|
|
"/api/v1/knowledge/unlink",
|
||
|
|
params={"knowledge_id": test_knowledge.id, "agent_id": str(test_agent.id)},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
remove_agent.assert_called_once()
|
||
|
|
assert remove_agent.call_args.args[2] == test_agent.id
|
||
|
|
|
||
|
|
|
||
|
|
def test_unlink_rolls_back_when_the_vector_sync_fails(
|
||
|
|
client: TestClient, test_knowledge, test_agent, db
|
||
|
|
):
|
||
|
|
"""The mirror risk: deleting the link row but leaving the agent id in the
|
||
|
|
vector filters keeps the source retrievable by an agent the dashboard shows
|
||
|
|
as unlinked — knowledge the user believes they revoked.
|
||
|
|
"""
|
||
|
|
_vector_backed(test_knowledge, db)
|
||
|
|
db.add(KnowledgeToAgent(knowledge_id=test_knowledge.id, agent_id=test_agent.id))
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"app.api.knowledge.knowledge_vector_links.remove_agent",
|
||
|
|
side_effect=Exception("vector store unavailable"),
|
||
|
|
):
|
||
|
|
response = client.delete(
|
||
|
|
"/api/v1/knowledge/unlink",
|
||
|
|
params={"knowledge_id": test_knowledge.id, "agent_id": str(test_agent.id)},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 500
|
||
|
|
db.expire_all()
|
||
|
|
still_linked = (
|
||
|
|
db.query(KnowledgeToAgent)
|
||
|
|
.filter(
|
||
|
|
KnowledgeToAgent.knowledge_id == test_knowledge.id,
|
||
|
|
KnowledgeToAgent.agent_id == test_agent.id,
|
||
|
|
)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
assert still_linked is not None, "link row was deleted despite a failed vector sync"
|
||
|
|
|
||
|
|
|
||
|
|
def test_vector_store_updates_are_delegated_not_hand_rolled():
|
||
|
|
"""Neither handler may build vector-store SQL itself.
|
||
|
|
|
||
|
|
The previous version of this test grepped the handler source for the string
|
||
|
|
':new_filters' — which was the *broken* construct: ':new_filters::jsonb' is
|
||
|
|
not parsed as a bind parameter at all, so the statement failed at the driver
|
||
|
|
with a syntax error while this test stayed green. Assert the structural
|
||
|
|
property instead; the binding and injection behaviour is covered for real in
|
||
|
|
tests/services/test_knowledge_vector_links.py.
|
||
|
|
"""
|
||
|
|
import inspect
|
||
|
|
from app.api.knowledge import link_knowledge_to_agent, unlink_knowledge_from_agent
|
||
|
|
|
||
|
|
for handler in (link_knowledge_to_agent, unlink_knowledge_from_agent):
|
||
|
|
source = inspect.getsource(handler)
|
||
|
|
assert "text(" not in source, \
|
||
|
|
f"{handler.__name__} should delegate to knowledge_vector_links, not build SQL"
|
||
|
|
assert "db.execute(" not in source, \
|
||
|
|
f"{handler.__name__} should not run raw statements against the vector table"
|
||
|
|
assert "knowledge_vector_links." in source, \
|
||
|
|
f"{handler.__name__} should sync the vector store via knowledge_vector_links"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unlink_knowledge_from_agent(client: TestClient, test_knowledge, test_agent, db):
|
||
|
|
"""Test unlinking knowledge from an agent"""
|
||
|
|
# First link the knowledge
|
||
|
|
link = KnowledgeToAgent(
|
||
|
|
knowledge_id=test_knowledge.id,
|
||
|
|
agent_id=test_agent.id
|
||
|
|
)
|
||
|
|
db.add(link)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
response = client.delete(
|
||
|
|
"/api/v1/knowledge/unlink",
|
||
|
|
params={
|
||
|
|
"knowledge_id": test_knowledge.id,
|
||
|
|
"agent_id": str(test_agent.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["message"] == "Knowledge unlinked from agent successfully"
|
||
|
|
|
||
|
|
def test_get_knowledge_by_agent(client: TestClient, test_knowledge, test_agent, db):
|
||
|
|
"""Test getting knowledge entries by agent"""
|
||
|
|
# Link knowledge to agent
|
||
|
|
link = KnowledgeToAgent(
|
||
|
|
knowledge_id=test_knowledge.id,
|
||
|
|
agent_id=test_agent.id
|
||
|
|
)
|
||
|
|
db.add(link)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
response = client.get(
|
||
|
|
f"/api/v1/knowledge/agent/{test_agent.id}",
|
||
|
|
params={"page": 1, "page_size": 10}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "knowledge" in data
|
||
|
|
assert len(data["knowledge"]) == 1
|
||
|
|
assert data["knowledge"][0]["id"] == test_knowledge.id
|
||
|
|
# The source reports which agents it is linked to.
|
||
|
|
agents = data["knowledge"][0]["agents"]
|
||
|
|
assert [a["id"] for a in agents] == [str(test_agent.id)]
|
||
|
|
assert agents[0]["name"] == (test_agent.display_name or test_agent.name)
|
||
|
|
assert "pagination" in data
|
||
|
|
assert data["pagination"]["page"] == 1
|
||
|
|
assert data["pagination"]["page_size"] == 10
|
||
|
|
|
||
|
|
def test_get_knowledge_by_organization(client: TestClient, test_knowledge, test_organization):
|
||
|
|
"""Test getting knowledge entries by organization"""
|
||
|
|
response = client.get(
|
||
|
|
f"/api/v1/knowledge/organization/{test_organization.id}",
|
||
|
|
params={"page": 1, "page_size": 10}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "knowledge" in data
|
||
|
|
assert len(data["knowledge"]) == 1
|
||
|
|
assert data["knowledge"][0]["id"] == test_knowledge.id
|
||
|
|
assert "pagination" in data
|
||
|
|
assert data["pagination"]["page"] == 1
|
||
|
|
assert data["pagination"]["page_size"] == 10
|
||
|
|
|
||
|
|
def test_get_queue_status(client: TestClient, test_knowledge_queue):
|
||
|
|
"""Test getting queue status"""
|
||
|
|
response = client.get(f"/api/v1/knowledge/queue/{test_knowledge_queue.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["id"] == test_knowledge_queue.id
|
||
|
|
assert data["status"] == "pending"
|
||
|
|
|
||
|
|
def test_get_organization_queue_items(client: TestClient, test_organization, test_knowledge_queue):
|
||
|
|
"""The org queue endpoint returns in-flight items for the org."""
|
||
|
|
response = client.get(f"/api/v1/knowledge/queue/organization/{test_organization.id}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
sources = [item["source"] for item in response.json()["queue_items"]]
|
||
|
|
assert test_knowledge_queue.source in sources
|
||
|
|
|
||
|
|
|
||
|
|
def test_failed_queue_items_carry_a_plain_reason(client: TestClient, db: Session, test_organization, test_user):
|
||
|
|
"""Raw errors stay in `error`; people get one sentence in `reason`."""
|
||
|
|
db.add(KnowledgeQueue(organization_id=test_organization.id, user_id=test_user.id, source_type="website",
|
||
|
|
source="https://nope.invalid", status=QueueStatus.FAILED,
|
||
|
|
error="Traceback: KeyError 'x'", created_at=datetime.now(timezone.utc)))
|
||
|
|
db.commit()
|
||
|
|
response = client.get(f"/api/v1/knowledge/queue/organization/{test_organization.id}")
|
||
|
|
(item,) = [i for i in response.json()["queue_items"] if i["source"] == "https://nope.invalid"]
|
||
|
|
assert item["reason"].startswith("We couldn't read any pages there")
|
||
|
|
assert "Traceback" not in item["reason"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_organization_queue_items_wrong_org(client: TestClient):
|
||
|
|
"""The org queue endpoint rejects a different organization."""
|
||
|
|
response = client.get(f"/api/v1/knowledge/queue/organization/{uuid4()}")
|
||
|
|
assert response.status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_processor_status(client: TestClient):
|
||
|
|
"""Test getting processor status"""
|
||
|
|
response = client.get("/api/v1/knowledge/processor/status")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "queue_status" in data
|
||
|
|
assert "is_running" in data
|
||
|
|
assert "last_run" in data
|
||
|
|
assert "error" in data
|
||
|
|
|
||
|
|
|
||
|
|
def test_processor_status_failure_does_not_return_the_exception(client: TestClient):
|
||
|
|
"""A failure here used to answer 200 with {"error": str(e)}.
|
||
|
|
|
||
|
|
These routes report on infrastructure, so the exception text is a database
|
||
|
|
or Redis error — connection strings and SQL, handed to whoever asked. The
|
||
|
|
real reason belongs in the log only.
|
||
|
|
"""
|
||
|
|
leak = "could not connect to postgresql://admin:hunter2@10.0.0.4/app"
|
||
|
|
with patch("app.api.knowledge.PROCESSOR_STATUS", new_callable=PropertyMock) as status:
|
||
|
|
status.side_effect = RuntimeError(leak)
|
||
|
|
response = client.get("/api/v1/knowledge/processor/status")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
body = response.text
|
||
|
|
assert "hunter2" not in body
|
||
|
|
assert "postgresql://" not in body
|
||
|
|
|
||
|
|
|
||
|
|
def test_queue_status_failure_does_not_return_the_exception(client: TestClient, test_knowledge_queue):
|
||
|
|
leak = "relation \"knowledge_queue\" does not exist"
|
||
|
|
with patch("app.api.knowledge.KnowledgeQueueRepository") as repo:
|
||
|
|
repo.side_effect = RuntimeError(leak)
|
||
|
|
response = client.get(f"/api/v1/knowledge/queue/{test_knowledge_queue.id}")
|
||
|
|
|
||
|
|
assert "does not exist" not in response.text
|
||
|
|
|
||
|
|
def test_delete_knowledge(client: TestClient, test_knowledge):
|
||
|
|
"""Test deleting knowledge"""
|
||
|
|
response = client.delete(f"/api/v1/knowledge/{test_knowledge.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["message"] == "Knowledge source deleted successfully"
|
||
|
|
|
||
|
|
# Negative test cases
|
||
|
|
|
||
|
|
def test_upload_invalid_file(client: TestClient, test_organization):
|
||
|
|
"""Test uploading invalid file type is rejected"""
|
||
|
|
# A text file must be rejected by content-type validation
|
||
|
|
text_content = b"This is not a PDF file"
|
||
|
|
files = [
|
||
|
|
("files", ("test.txt", io.BytesIO(text_content), "text/plain"))
|
||
|
|
]
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/upload/pdf",
|
||
|
|
files=files,
|
||
|
|
data={
|
||
|
|
"org_id": str(test_organization.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "unsupported content type" in response.json()["detail"]
|
||
|
|
|
||
|
|
def test_a_pdf_over_the_page_limit_is_refused_at_upload(client: TestClient, test_organization, db):
|
||
|
|
from tests.knowledge.test_pdf_limits import pdf_with_pages
|
||
|
|
files = [("files", ("catalogue.pdf", io.BytesIO(pdf_with_pages(4)), "application/pdf"))]
|
||
|
|
|
||
|
|
with patch('app.core.config.settings.KB_PDF_MAX_PAGES', 3):
|
||
|
|
response = client.post("/api/v1/knowledge/upload/pdf", files=files,
|
||
|
|
data={"org_id": str(test_organization.id)})
|
||
|
|
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert response.json()["detail"] == (
|
||
|
|
"catalogue.pdf: This PDF has 4 pages; the limit is 3. Upload the part you need, "
|
||
|
|
"or split it into smaller files.")
|
||
|
|
assert db.query(KnowledgeQueue).filter(KnowledgeQueue.organization_id == test_organization.id).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_pdf_within_the_page_limit_is_queued(client: TestClient, test_organization, tmp_path):
|
||
|
|
from tests.knowledge.test_pdf_limits import pdf_with_pages
|
||
|
|
files = [("files", ("catalogue.pdf", io.BytesIO(pdf_with_pages(3)), "application/pdf"))]
|
||
|
|
|
||
|
|
with patch('app.core.config.settings.KB_PDF_MAX_PAGES', 3), \
|
||
|
|
patch('app.core.config.settings.S3_FILE_STORAGE', False), \
|
||
|
|
patch.object(knowledge_router, 'TEMP_DIR', str(tmp_path)):
|
||
|
|
response = client.post("/api/v1/knowledge/upload/pdf", files=files,
|
||
|
|
data={"org_id": str(test_organization.id)})
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert len(response.json()["queue_items"]) == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_upload_fake_pdf_rejected(client: TestClient, test_organization):
|
||
|
|
"""Test uploading a file with a PDF content type but non-PDF bytes is rejected"""
|
||
|
|
# Correct content type, but the magic-byte check must reject it
|
||
|
|
files = [
|
||
|
|
("files", ("fake.pdf", io.BytesIO(b"This is not a PDF file"), "application/pdf"))
|
||
|
|
]
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/upload/pdf",
|
||
|
|
files=files,
|
||
|
|
data={
|
||
|
|
"org_id": str(test_organization.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "not a valid PDF" in response.json()["detail"]
|
||
|
|
|
||
|
|
def test_add_invalid_urls(client: TestClient, test_organization):
|
||
|
|
"""Test adding invalid URLs"""
|
||
|
|
urls_data = {
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"pdf_urls": ["not_a_url"],
|
||
|
|
"websites": ["not_a_url"]
|
||
|
|
}
|
||
|
|
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json=urls_data)
|
||
|
|
|
||
|
|
# The API now validates URL formats strictly and returns 422 for invalid inputs
|
||
|
|
assert response.status_code == 422
|
||
|
|
data = response.json()
|
||
|
|
assert "detail" in data
|
||
|
|
# Ensure validation errors point to either pdf_urls or websites fields
|
||
|
|
errors = data.get("detail", [])
|
||
|
|
assert any(
|
||
|
|
any(part in ("websites", "pdf_urls") for part in err.get("loc", []))
|
||
|
|
for err in errors
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_link_nonexistent_knowledge(client: TestClient, test_agent):
|
||
|
|
"""Test linking nonexistent knowledge"""
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/link",
|
||
|
|
params={
|
||
|
|
"knowledge_id": 999999, # Nonexistent ID
|
||
|
|
"agent_id": str(test_agent.id)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert "Knowledge source not found or unauthorized access" in response.json()["detail"]
|
||
|
|
|
||
|
|
def test_get_nonexistent_queue(client: TestClient):
|
||
|
|
"""Test getting nonexistent queue status"""
|
||
|
|
response = client.get("/api/v1/knowledge/queue/999999")
|
||
|
|
|
||
|
|
assert response.status_code == 200 # API returns 200 with error message
|
||
|
|
data = response.json()
|
||
|
|
assert "error" in data
|
||
|
|
assert data["error"] == "Queue item not found"
|
||
|
|
|
||
|
|
|
||
|
|
# Test cases for add_subpage endpoint
|
||
|
|
def test_add_subpage_knowledge_not_found(client: TestClient):
|
||
|
|
"""Test adding subpage to non-existent knowledge source"""
|
||
|
|
response = client.post(
|
||
|
|
"/api/v1/knowledge/999999/subpage",
|
||
|
|
json={
|
||
|
|
"subpage_name": "test_subpage",
|
||
|
|
"content": "Test content for subpage"
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert "Knowledge source not found" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_subpage_unauthorized(client: TestClient, test_knowledge):
|
||
|
|
"""Test adding subpage to knowledge from different organization"""
|
||
|
|
# Create a knowledge source with different org_id
|
||
|
|
from uuid import uuid4
|
||
|
|
from app.models.organization import Organization
|
||
|
|
from tests.conftest import TestingSessionLocal
|
||
|
|
|
||
|
|
db = TestingSessionLocal()
|
||
|
|
try:
|
||
|
|
# Create a different organization
|
||
|
|
other_org = Organization(
|
||
|
|
id=uuid4(),
|
||
|
|
name="Other Org",
|
||
|
|
domain="other.com",
|
||
|
|
timezone="UTC"
|
||
|
|
)
|
||
|
|
db.add(other_org)
|
||
|
|
|
||
|
|
# Create knowledge for different org
|
||
|
|
other_knowledge = Knowledge(
|
||
|
|
source="other.pdf",
|
||
|
|
source_type=SourceType.FILE,
|
||
|
|
organization_id=other_org.id,
|
||
|
|
created_at=datetime.now(timezone.utc)
|
||
|
|
)
|
||
|
|
db.add(other_knowledge)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(other_knowledge)
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
f"/api/v1/knowledge/{other_knowledge.id}/subpage",
|
||
|
|
json={
|
||
|
|
"subpage_name": "test_subpage",
|
||
|
|
"content": "Test content"
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 403
|
||
|
|
assert "Unauthorized access to knowledge source" in response.json()["detail"]
|
||
|
|
finally:
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_subpage_no_table_name(client: TestClient, test_knowledge):
|
||
|
|
"""Test adding subpage when knowledge has no vector database table"""
|
||
|
|
# test_knowledge by default has no table_name set
|
||
|
|
response = client.post(
|
||
|
|
f"/api/v1/knowledge/{test_knowledge.id}/subpage",
|
||
|
|
json={
|
||
|
|
"subpage_name": "test_subpage",
|
||
|
|
"content": "Test content for subpage"
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "Knowledge source has no vector database table" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_subpage_integration_logic():
|
||
|
|
"""Test that add_subpage endpoint has correct integration points"""
|
||
|
|
# This test verifies the endpoint exists and has the expected logic flow
|
||
|
|
# Actual database operations with pgvector are tested in integration tests
|
||
|
|
|
||
|
|
# Verify the endpoint is registered
|
||
|
|
from app.api.knowledge import router
|
||
|
|
route_found = False
|
||
|
|
for route in router.routes:
|
||
|
|
if hasattr(route, 'path') and '/{knowledge_id}/subpage' in route.path:
|
||
|
|
route_found = True
|
||
|
|
break
|
||
|
|
|
||
|
|
assert route_found, "add_subpage endpoint should be registered"
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_subpage_with_enterprise_limits_mocked():
|
||
|
|
"""Test that enterprise limits are checked in add_subpage endpoint"""
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import patch, MagicMock
|
||
|
|
from app.models.user import User
|
||
|
|
from app.models.knowledge import Knowledge, SourceType
|
||
|
|
from uuid import uuid4
|
||
|
|
import app.api.knowledge as knowledge_module
|
||
|
|
|
||
|
|
# Skip this test if enterprise module is not available
|
||
|
|
if not knowledge_module.HAS_ENTERPRISE:
|
||
|
|
pytest.skip("Enterprise module not available")
|
||
|
|
|
||
|
|
from app.api.knowledge import add_subpage
|
||
|
|
from fastapi import HTTPException
|
||
|
|
|
||
|
|
# Create mock objects
|
||
|
|
mock_db = MagicMock()
|
||
|
|
mock_user = User(
|
||
|
|
id=uuid4(),
|
||
|
|
email="test@test.com",
|
||
|
|
hashed_password="hashed",
|
||
|
|
organization_id=uuid4(),
|
||
|
|
role_id=1,
|
||
|
|
is_active=True,
|
||
|
|
full_name="Test User"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Create mock knowledge with table_name and schema set
|
||
|
|
mock_knowledge = Knowledge(
|
||
|
|
id=1,
|
||
|
|
source="test.pdf",
|
||
|
|
source_type=SourceType.FILE,
|
||
|
|
organization_id=mock_user.organization_id,
|
||
|
|
table_name="test_table",
|
||
|
|
schema="public"
|
||
|
|
)
|
||
|
|
mock_knowledge.agent_links = []
|
||
|
|
|
||
|
|
# Mock repository
|
||
|
|
with patch('app.api.knowledge.KnowledgeRepository') as mock_repo_class:
|
||
|
|
mock_repo = MagicMock()
|
||
|
|
mock_repo.get_by_id.return_value = mock_knowledge
|
||
|
|
mock_repo_class.return_value = mock_repo
|
||
|
|
|
||
|
|
# Mock subscription check with limit reached
|
||
|
|
with patch('app.enterprise.repositories.subscription.SubscriptionRepository') as mock_sub_repo_class:
|
||
|
|
# Create subscription with plan at limit
|
||
|
|
mock_subscription = MagicMock()
|
||
|
|
mock_plan = MagicMock()
|
||
|
|
mock_plan.max_sub_pages = 2
|
||
|
|
mock_subscription.plan = mock_plan
|
||
|
|
|
||
|
|
mock_sub_repo = MagicMock()
|
||
|
|
mock_sub_repo.get_active_subscription.return_value = mock_subscription
|
||
|
|
mock_sub_repo_class.return_value = mock_sub_repo
|
||
|
|
|
||
|
|
# Mock the database count query to return 2 (at limit)
|
||
|
|
mock_result = MagicMock()
|
||
|
|
mock_result.count = 2
|
||
|
|
mock_db.execute.return_value.fetchone.return_value = mock_result
|
||
|
|
|
||
|
|
# Should raise 402 error
|
||
|
|
with pytest.raises(HTTPException) as exc_info:
|
||
|
|
# Call the endpoint function directly
|
||
|
|
import asyncio
|
||
|
|
result = asyncio.run(add_subpage(
|
||
|
|
knowledge_id=1,
|
||
|
|
subpage_name="test",
|
||
|
|
content="test content",
|
||
|
|
current_user=mock_user,
|
||
|
|
db=mock_db
|
||
|
|
))
|
||
|
|
|
||
|
|
assert exc_info.value.status_code == 402
|
||
|
|
assert "Subpage limit reached" in exc_info.value.detail
|
||
|
|
|
||
|
|
|
||
|
|
# Test cases for update_page_content / delete_page endpoints
|
||
|
|
def test_update_page_empty_content(client: TestClient, test_knowledge):
|
||
|
|
"""Empty page content is rejected before any DB work."""
|
||
|
|
response = client.put(
|
||
|
|
f"/api/v1/knowledge/{test_knowledge.id}/page/some-page",
|
||
|
|
json={"content": " "}
|
||
|
|
)
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "Page content cannot be empty" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_update_page_knowledge_not_found(client: TestClient):
|
||
|
|
"""Updating a page on a non-existent knowledge source returns 404."""
|
||
|
|
response = client.put(
|
||
|
|
"/api/v1/knowledge/999999/page/some-page",
|
||
|
|
json={"content": "New content"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert "Knowledge source not found" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_update_page_no_table_name(client: TestClient, test_knowledge):
|
||
|
|
"""Updating a page when the source has no vector table returns 400."""
|
||
|
|
response = client.put(
|
||
|
|
f"/api/v1/knowledge/{test_knowledge.id}/page/some-page",
|
||
|
|
json={"content": "New content"}
|
||
|
|
)
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "Knowledge source has no vector database table" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_delete_page_knowledge_not_found(client: TestClient):
|
||
|
|
"""Deleting a page on a non-existent knowledge source returns 404."""
|
||
|
|
response = client.delete("/api/v1/knowledge/999999/page/some-page")
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert "Knowledge source not found" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_delete_page_no_table_name(client: TestClient, test_knowledge):
|
||
|
|
"""Deleting a page when the source has no vector table returns 400."""
|
||
|
|
response = client.delete(f"/api/v1/knowledge/{test_knowledge.id}/page/some-page")
|
||
|
|
assert response.status_code == 400
|
||
|
|
assert "Knowledge source has no vector database table" in response.json()["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_page_endpoints_registered():
|
||
|
|
"""The page update/delete endpoints are registered on the router."""
|
||
|
|
from app.api.knowledge import router
|
||
|
|
paths = [route.path for route in router.routes if hasattr(route, 'path')]
|
||
|
|
assert any('/{knowledge_id}/page/{page_id:path}' in p for p in paths), \
|
||
|
|
"page endpoints should be registered"
|
||
|
|
|
||
|
|
|
||
|
|
def test_replace_page_upserts_before_deleting(monkeypatch):
|
||
|
|
"""replace_page persists the new content (upsert) before clearing old chunks,
|
||
|
|
so an insert failure can never leave the page empty. Ordering is asserted via
|
||
|
|
a call log."""
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
from app.knowledge import page_editor
|
||
|
|
|
||
|
|
calls = []
|
||
|
|
|
||
|
|
knowledge = SimpleNamespace(
|
||
|
|
schema="ai", table_name="d_test", source="site.com",
|
||
|
|
organization_id=uuid4(), agent_links=[],
|
||
|
|
)
|
||
|
|
|
||
|
|
# One existing chunk for the page so replace_page proceeds.
|
||
|
|
existing_chunk = SimpleNamespace(id="site.com/docs", content="old", meta_data={"url": "site.com/docs"})
|
||
|
|
monkeypatch.setattr(page_editor, "get_page_chunks", lambda db, k, pid: [existing_chunk])
|
||
|
|
|
||
|
|
fake_manager = MagicMock()
|
||
|
|
fake_manager.vector_db.embedder = object()
|
||
|
|
fake_manager.vector_db.upsert.side_effect = lambda *a, **kw: calls.append("upsert")
|
||
|
|
monkeypatch.setattr(page_editor, "get_manager", lambda org_id: fake_manager)
|
||
|
|
new_chunks = [
|
||
|
|
SimpleNamespace(id="site.com/docs::1", embedding=[0.1]),
|
||
|
|
SimpleNamespace(id="site.com/docs::2", embedding=[0.2]),
|
||
|
|
]
|
||
|
|
monkeypatch.setattr(page_editor, "embed_page", lambda *a, **kw: new_chunks)
|
||
|
|
|
||
|
|
def fake_delete(db, k, pid, keep_ids):
|
||
|
|
calls.append(("delete", keep_ids))
|
||
|
|
return 1
|
||
|
|
monkeypatch.setattr(page_editor, "delete_page_chunks_except", fake_delete)
|
||
|
|
|
||
|
|
db = MagicMock()
|
||
|
|
db.commit.side_effect = lambda: calls.append("commit")
|
||
|
|
|
||
|
|
replaced = page_editor.replace_page(db, knowledge, "site.com/docs", "new content", title="Docs")
|
||
|
|
|
||
|
|
assert replaced == 1
|
||
|
|
# upsert (persist new content) must happen before the destructive delete,
|
||
|
|
# and the delete must spare exactly the rows just written.
|
||
|
|
assert calls[0] == "upsert"
|
||
|
|
assert calls[1] == ("delete", ["site.com/docs::1", "site.com/docs::2"])
|
||
|
|
assert "commit" in calls
|
||
|
|
|
||
|
|
|
||
|
|
# Test cases for crawl-scope (max_links) on add/urls
|
||
|
|
def test_add_urls_crawl_scope_clamps_max_links(client: TestClient, test_organization, db):
|
||
|
|
"""A website queued with max_links=1 ('this page only') records that cap."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://scope.example.com"],
|
||
|
|
"max_links": 1,
|
||
|
|
})
|
||
|
|
assert response.status_code == 200
|
||
|
|
item = db.query(KnowledgeQueue).filter(
|
||
|
|
KnowledgeQueue.source == "https://scope.example.com"
|
||
|
|
).first()
|
||
|
|
assert item is not None
|
||
|
|
assert item.queue_metadata.get("max_links") == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_urls_rejects_zero_max_links(client: TestClient, test_organization):
|
||
|
|
"""max_links below 1 is rejected by request validation."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/urls", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"websites": ["https://scope2.example.com"],
|
||
|
|
"max_links": 0,
|
||
|
|
})
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
def test_create_text_source_creates_the_vector_table_before_writing(monkeypatch):
|
||
|
|
"""An org whose first knowledge source is pasted text has no vector table yet
|
||
|
|
(only URL/PDF ingestion used to create it), so the write must create it first."""
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
from app.knowledge import page_editor
|
||
|
|
|
||
|
|
calls = []
|
||
|
|
fake_manager = MagicMock()
|
||
|
|
fake_manager.vector_db.table_name = "d_test"
|
||
|
|
fake_manager.vector_db.schema = "ai"
|
||
|
|
fake_manager.vector_db.create.side_effect = lambda: calls.append("create")
|
||
|
|
fake_manager.vector_db.upsert.side_effect = lambda *a, **kw: calls.append("upsert")
|
||
|
|
monkeypatch.setattr(page_editor, "KnowledgeManager", lambda **kw: fake_manager)
|
||
|
|
monkeypatch.setattr(page_editor, "embed_page", lambda *a, **kw: [SimpleNamespace(id="t", embedding=[0.1])])
|
||
|
|
|
||
|
|
repo = MagicMock()
|
||
|
|
repo.get_by_sources.return_value = []
|
||
|
|
repo.create.side_effect = lambda knowledge: knowledge
|
||
|
|
monkeypatch.setattr(page_editor, "KnowledgeRepository", lambda db: repo)
|
||
|
|
|
||
|
|
page_editor.create_text_source(MagicMock(), uuid4(), "Returns", "Returns within 45 days.")
|
||
|
|
|
||
|
|
assert calls == ["create", "upsert"]
|
||
|
|
|
||
|
|
|
||
|
|
# Test cases for add/text endpoint
|
||
|
|
def test_add_text_unauthorized_org(client: TestClient):
|
||
|
|
"""Adding a text source for a different org is rejected."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/text", json={
|
||
|
|
"org_id": str(uuid4()),
|
||
|
|
"title": "Refund policy",
|
||
|
|
"content": "Full refund within 14 days.",
|
||
|
|
})
|
||
|
|
assert response.status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_text_empty_content_rejected(client: TestClient, test_organization):
|
||
|
|
"""Blank content is rejected by request validation."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/text", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"title": "Refund policy",
|
||
|
|
"content": " ",
|
||
|
|
})
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_text_agent_not_found(client: TestClient, test_organization):
|
||
|
|
"""A text source targeting a non-existent agent returns 404."""
|
||
|
|
response = client.post("/api/v1/knowledge/add/text", json={
|
||
|
|
"org_id": str(test_organization.id),
|
||
|
|
"title": "Refund policy",
|
||
|
|
"content": "Full refund within 14 days.",
|
||
|
|
"agent_id": str(uuid4()),
|
||
|
|
})
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
def test_add_text_endpoint_registered():
|
||
|
|
"""The add/text endpoint is registered on the router."""
|
||
|
|
from app.api.knowledge import router
|
||
|
|
assert any(
|
||
|
|
hasattr(route, 'path') and route.path == '/add/text' for route in router.routes
|
||
|
|
), "add/text endpoint should be registered"
|
||
|
|
|
||
|
|
class TestExploreProgressIsScopedToTheExploreOrg:
|
||
|
|
"""The explore progress poll is public, so it must only ever expose the
|
||
|
|
shared explore organization's jobs — queue ids are sequential."""
|
||
|
|
|
||
|
|
def test_another_orgs_queue_item_is_not_exposed(self, client, test_knowledge_queue):
|
||
|
|
"""test_knowledge_queue belongs to a normal tenant, not the explore org"""
|
||
|
|
response = client.get(
|
||
|
|
f"/api/v1/knowledge/explore/progress/{test_knowledge_queue.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
body = response.json()
|
||
|
|
# The tenant's crawl source must not leak in any form
|
||
|
|
assert "test.pdf" not in str(body)
|
||
|
|
|
||
|
|
def test_explore_org_queue_item_is_exposed(self, client, db, test_user):
|
||
|
|
"""The public explore flow still gets its own job's progress"""
|
||
|
|
from app.models.organization import Organization
|
||
|
|
|
||
|
|
explore_org = Organization(
|
||
|
|
id=UUID(settings.EXPLORE_SOURCE_ORG_ID),
|
||
|
|
name="Explore Org",
|
||
|
|
domain="explore.example.com",
|
||
|
|
timezone="UTC"
|
||
|
|
)
|
||
|
|
db.add(explore_org)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
queue = KnowledgeQueue(
|
||
|
|
organization_id=explore_org.id,
|
||
|
|
user_id=test_user.id,
|
||
|
|
source_type="website",
|
||
|
|
source="https://explore.example.com",
|
||
|
|
status=QueueStatus.PENDING,
|
||
|
|
created_at=datetime.now(timezone.utc)
|
||
|
|
)
|
||
|
|
db.add(queue)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(queue)
|
||
|
|
|
||
|
|
response = client.get(f"/api/v1/knowledge/explore/progress/{queue.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
|
||
|
|
class TestKnowledgeLinkAgentIsOrgScoped:
|
||
|
|
"""A knowledge source may only be linked to an agent in the same org."""
|
||
|
|
|
||
|
|
def _foreign_agent(self, db):
|
||
|
|
from app.models.organization import Organization
|
||
|
|
other_org = Organization(
|
||
|
|
id=uuid4(), name="Other Org", domain="other-kn.example.com", timezone="UTC")
|
||
|
|
db.add(other_org)
|
||
|
|
db.commit()
|
||
|
|
agent = Agent(
|
||
|
|
id=uuid4(),
|
||
|
|
name="Foreign Agent",
|
||
|
|
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||
|
|
instructions=["x"],
|
||
|
|
organization_id=other_org.id,
|
||
|
|
is_active=True,
|
||
|
|
)
|
||
|
|
db.add(agent)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(agent)
|
||
|
|
return agent
|
||
|
|
|
||
|
|
def test_link_rejects_agent_from_another_org(self, client, db, test_knowledge):
|
||
|
|
foreign_agent = self._foreign_agent(db)
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
f"/api/v1/knowledge/link?knowledge_id={test_knowledge.id}"
|
||
|
|
f"&agent_id={foreign_agent.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert response.json()["detail"] == "Agent not found"
|
||
|
|
|
||
|
|
def test_unlink_rejects_agent_from_another_org(self, client, db, test_knowledge):
|
||
|
|
foreign_agent = self._foreign_agent(db)
|
||
|
|
|
||
|
|
response = client.delete(
|
||
|
|
f"/api/v1/knowledge/unlink?knowledge_id={test_knowledge.id}"
|
||
|
|
f"&agent_id={foreign_agent.id}")
|
||
|
|
|
||
|
|
assert response.status_code == 404
|
||
|
|
assert response.json()["detail"] == "Agent not found"
|