""" 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"