diff --git a/backend/app/main.py b/backend/app/main.py index 7b6f399..8b9278f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,33 @@ class ConversationDetailResponse(BaseModel): offset: int +class DocumentSummary(BaseModel): + id: int + title: str + source: str + created_at: Optional[datetime] = None + content_preview: Optional[str] = None + + +class DocumentListResponse(BaseModel): + items: List[DocumentSummary] + total: int + limit: int + offset: int + + +class DocumentDetail(BaseModel): + id: int + title: str + content: str + source: str + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + # Startup/Shutdown Events @app.on_event("startup") async def startup_event(): @@ -196,25 +223,45 @@ async def add_document(doc: DocumentRequest, db: Session = Depends(get_db)): raise HTTPException(status_code=500, detail=str(e)) -@app.get("/api/documents") -async def list_documents(db: Session = Depends(get_db)): - """ - List all documents in the knowledge base - """ - try: - documents = db.query(Document).all() - return [ - { - "id": doc.id, - "title": doc.title, - "source": doc.source, - "created_at": doc.created_at - } - for doc in documents - ] - except Exception as e: - logger.error(f"Error listing documents: {e}") - raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/documents", response_model=DocumentListResponse) +async def list_documents( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + source: str = None, + db: Session = Depends(get_db), +): + """List documents in the knowledge base, most recently created first.""" + base = db.query(Document) + if source: + base = base.filter(Document.source == source) + + total = base.count() + documents = ( + base.order_by(Document.created_at.desc(), Document.id.desc()) + .offset(offset) + .limit(limit) + .all() + ) + items = [ + DocumentSummary( + id=doc.id, + title=doc.title, + source=doc.source, + created_at=doc.created_at, + content_preview=doc.content[:120] if doc.content else None, + ) + for doc in documents + ] + return DocumentListResponse(items=items, total=total, limit=limit, offset=offset) + + +@app.get("/api/documents/{document_id}", response_model=DocumentDetail) +async def get_document(document_id: int, db: Session = Depends(get_db)): + """Return one document with its full content.""" + doc = db.query(Document).filter(Document.id == document_id).first() + if doc is None: + raise HTTPException(status_code=404, detail="Document not found") + return doc @app.get("/api/messages", response_model=List[MessageResponse]) diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py new file mode 100644 index 0000000..e36fbb6 --- /dev/null +++ b/backend/tests/test_documents.py @@ -0,0 +1,151 @@ +"""Tests for the document list and detail endpoints.""" +import os + +os.environ.setdefault("SLACK_BOT_TOKEN", "[REDACTED]") +os.environ.setdefault("SLACK_SIGNING_SECRET", "test-signing-secret") + +from datetime import datetime, timedelta + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, Document, get_db + +with patch("slack_sdk.WebClient.auth_test", return_value={ + "ok": True, "bot_id": "BTEST", "user_id": "UTEST", "team_id": "TTEST" +}): + from app.main import app + +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base.metadata.create_all(bind=engine) + + +def override_get_db(): + db = TestingSession() + try: + yield db + finally: + db.close() + + +client = TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture(autouse=True) +def use_document_db(): + previous = app.dependency_overrides.get(get_db) + app.dependency_overrides[get_db] = override_get_db + yield + if previous is None: + app.dependency_overrides.pop(get_db, None) + else: + app.dependency_overrides[get_db] = previous + +BASE_TIME = datetime(2026, 1, 1, 9, 0, 0) + + +def _doc(title, content, source, minutes): + return Document( + title=title, + content=content, + source=source, + created_at=BASE_TIME + timedelta(minutes=minutes), + updated_at=BASE_TIME + timedelta(minutes=minutes), + ) + + +@pytest.fixture(autouse=True) +def seed(): + db = TestingSession() + db.query(Document).delete() + db.commit() + db.add_all([ + _doc("VPN guide", "Use the IT portal to reset VPN.", "api", 0), + _doc("Office hours", "Nine to five.", "docs", 2), + _doc("Long guide", "x" * 200, "api", 10), + ]) + db.commit() + seeded_ids = [doc.id for doc in db.query(Document).order_by(Document.id).all()] + db.close() + yield seeded_ids + db = TestingSession() + db.query(Document).delete() + db.commit() + db.close() + + +class TestListDocuments: + def test_lists_documents_most_recent_first(self): + response = client.get("/api/documents") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 3 + assert data["limit"] == 50 + assert data["offset"] == 0 + assert [item["title"] for item in data["items"]] == [ + "Long guide", "Office hours", "VPN guide", + ] + first = data["items"][0] + assert first["source"] == "api" + assert first["content_preview"] == "x" * 120 + + def test_preview_and_fields(self): + data = client.get("/api/documents").json() + by_title = {i["title"]: i for i in data["items"]} + assert by_title["VPN guide"]["content_preview"] == "Use the IT portal to reset VPN." + assert by_title["VPN guide"]["created_at"] is not None + assert "content" not in by_title["VPN guide"] + + def test_pagination(self): + page_one = client.get("/api/documents?limit=2&offset=0").json() + page_two = client.get("/api/documents?limit=2&offset=2").json() + assert page_one["total"] == 3 + assert len(page_one["items"]) == 2 + assert len(page_two["items"]) == 1 + assert page_one["items"][0]["id"] != page_two["items"][0]["id"] + + def test_source_filter(self): + data = client.get("/api/documents?source=docs").json() + assert data["total"] == 1 + assert data["items"][0]["title"] == "Office hours" + + def test_invalid_pagination_rejected(self): + assert client.get("/api/documents?limit=0").status_code == 422 + assert client.get("/api/documents?limit=201").status_code == 422 + assert client.get("/api/documents?offset=-1").status_code == 422 + + def test_empty_database(self): + db = TestingSession() + db.query(Document).delete() + db.commit() + db.close() + data = client.get("/api/documents").json() + assert data == {"items": [], "total": 0, "limit": 50, "offset": 0} + + +class TestDocumentDetail: + def test_returns_full_content(self, seed): + doc_id = seed[0] + response = client.get(f"/api/documents/{doc_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == doc_id + assert data["title"] == "VPN guide" + assert data["content"] == "Use the IT portal to reset VPN." + assert data["source"] == "api" + assert data["created_at"] is not None + + def test_missing_document_is_404(self): + response = client.get("/api/documents/999999") + assert response.status_code == 404 + assert response.json()["detail"] == "Document not found" diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 37a6bbb..ddcdbd4 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -16,6 +16,19 @@ export interface Document { title: string source: string created_at: string + content_preview?: string | null +} + +export interface DocumentDetail extends Document { + content: string + updated_at?: string | null +} + +export interface DocumentListResponse { + items: Document[] + total: number + limit: number + offset: number } export interface DocumentRequest { @@ -55,8 +68,28 @@ export async function askQuestion( return response.json() } -export async function listDocuments(): Promise { - const response = await fetch(`${API_BASE_URL}/api/documents`) +export async function listDocuments( + limit?: number, + offset?: number, + source?: string +): Promise { + const params = new URLSearchParams() + if (limit !== undefined) params.append('limit', limit.toString()) + if (offset !== undefined) params.append('offset', offset.toString()) + if (source) params.append('source', source) + + const url = `${API_BASE_URL}/api/documents${params.toString() ? `?${params}` : ''}` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`API error: ${response.status}`) + } + + return response.json() +} + +export async function getDocument(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents/${id}`) if (!response.ok) { throw new Error(`API error: ${response.status}`) diff --git a/mise.toml b/mise.toml index 73970ec..0af49c2 100644 --- a/mise.toml +++ b/mise.toml @@ -51,7 +51,7 @@ run = "docker compose ps" [tasks.test-backend] description = "Run backend tests" dir = "backend" -run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py tests/test_conversations.py -v" +run = "uv run pytest tests/test_file_extraction_units.py tests/test_logging_config.py tests/test_logging.py tests/test_agent_runtime.py tests/test_rules.py tests/test_conversations.py tests/test_documents.py -v" [tasks.migrate] description = "Run database migrations (upgrade to latest)"