From 16d58bd8247ac67d81eb75a56820f934b9fb7750 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:22:28 +0700 Subject: [PATCH] fix(api): reject blank document fields before indexing Validate document title, source and content before persistence. Preserve valid content formatting and cover rejected input, source defaults and successful indexing with offline tests. Co-authored-by: OpenAI Codex --- backend/app/main.py | 14 +++++++--- backend/tests/test_documents.py | 48 ++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 89726c1..e79373c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -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): diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py index e36fbb6..4fcbce9 100644 --- a/backend/tests/test_documents.py +++ b/backend/tests/test_documents.py @@ -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]") @@ -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" @@ -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