Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, StringConstraints
from pydantic import BaseModel, StringConstraints, field_validator
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
Expand Down Expand Up @@ -81,9 +81,17 @@ class RetrievalPreviewResponse(BaseModel):


class DocumentRequest(BaseModel):
title: str
title: NonBlankString
content: str
source: str = "api"
source: NonBlankString = "api"

@field_validator("content")
@classmethod
def content_must_not_be_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("Content must not be blank")
# Preserve code indentation and other meaningful document formatting.
return value


class MessageResponse(BaseModel):
Expand Down
48 changes: 47 additions & 1 deletion backend/tests/test_documents.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Tests for the document list and detail endpoints."""
"""Offline tests for document creation, listing and detail endpoints."""
import os

os.environ.setdefault("SLACK_BOT_TOKEN", "[REDACTED]")
Expand All @@ -15,6 +15,7 @@
from sqlalchemy.pool import StaticPool

from app.database import Base, Document, get_db
from app.rag import rag_engine

with patch("slack_sdk.WebClient.auth_test", return_value={
"ok": True, "bot_id": "BTEST", "user_id": "UTEST", "team_id": "TTEST"
Expand Down Expand Up @@ -149,3 +150,48 @@ 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"


class TestCreateDocument:
@pytest.mark.parametrize("field", ["title", "content", "source"])
@pytest.mark.parametrize("value", ["", " \n\t ", "\u2003\u00a0", None, 123])
def test_rejects_invalid_fields_without_writes_or_indexing(self, field, value):
payload = {"title": "VPN guide", "content": "Reset via IT.", "source": "api"}
payload[field] = value
with patch.object(rag_engine, "index_document") as index:
response = client.post("/api/documents", json=payload)
assert response.status_code == 422
assert any(error["loc"] == ["body", field] for error in response.json()["detail"])
index.assert_not_called()
with TestingSession() as db:
assert db.query(Document).count() == 3

@pytest.mark.parametrize("field", ["title", "content"])
def test_rejects_missing_required_fields(self, field):
payload = {"title": "VPN guide", "content": "Reset via IT."}
del payload[field]
with patch.object(rag_engine, "index_document") as index:
response = client.post("/api/documents", json=payload)
assert response.status_code == 422
index.assert_not_called()
with TestingSession() as db:
assert db.query(Document).count() == 3

@pytest.mark.parametrize("source", [None, " \tdocs\n "])
def test_creates_readable_document_and_indexes_preserved_content(self, source):
content = "\n def reset_vpn():\n return 'IT portal'\n\n"
payload = {"title": " \tVPN guide\n ", "content": content}
if source is not None:
payload["source"] = source
expected_source = "api" if source is None else "docs"
with patch.object(rag_engine, "index_document") as index:
response = client.post("/api/documents", json=payload)
assert response.status_code == 200
index.assert_called_once_with("VPN guide", content, expected_source)
document_id = response.json()["id"]
detail = client.get(f"/api/documents/{document_id}").json()
assert detail["title"] == "VPN guide"
assert detail["content"] == content
assert detail["source"] == expected_source
with TestingSession() as db:
assert db.query(Document).count() == 4
Loading