diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a13c499 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + backend: + name: Backend (pytest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: | + pytest -q + + frontend: + name: Frontend (lint, test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test (vitest coverage) + run: npm test diff --git a/.gitignore b/.gitignore index 24b5fc2..cfe6e02 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ docs/_build/ # Search index search_index/ + +# Claude local artifacts +codeagent_wrapper_smoke_test.md diff --git a/README.md b/README.md index 7530081..0c4e362 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,34 @@ ## Quick Start +Backend (Terminal 1): + ```bash -# Backend cd backend python -m venv venv source venv/bin/activate pip install -r requirements.txt -uvicorn main:app --reload +uvicorn app.main:app --reload +``` + +Frontend (Terminal 2): -# Frontend +```bash cd frontend npm install npm run dev ``` +## Docker (Dev) + +```bash +# From repo root +docker compose up + +# API health +curl http://localhost:8000/api/health +``` + ## Project Structure ``` @@ -46,6 +60,7 @@ doc-search/ ├── backend/ # FastAPI backend ├── frontend/ # React frontend ├── docs/ # Documentation +├── .github/ # CI (GitHub Actions) └── docker-compose.yml ``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..05379cd --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +# Backend +# Defaults are defined in backend/app/core/config.py and can be overridden by env vars. + +# sqlite (default) +DATABASE_URL=sqlite+aiosqlite:///./doc_search.db + +# Storage +UPLOAD_DIR=./uploads +INDEX_DIR=./search_index + +# CORS +# Accepts JSON array or comma-separated list. +# Examples: +# CORS_ORIGINS=["http://localhost:5173"] +# CORS_ORIGINS=http://localhost:5173,http://example.com +CORS_ORIGINS=["*"] + +# Credentials (cookies/Authorization). Default: false. +CORS_ALLOW_CREDENTIALS=false diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4b9d39e..387b39a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,14 +1,75 @@ from __future__ import annotations +import json +import os from dataclasses import dataclass, field +from typing import Optional + + +def _parse_bool(value: str) -> Optional[bool]: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + +def _parse_cors_origins(value: str) -> Optional[list[str]]: + raw = value.strip() + if not raw: + return None + + # Prefer JSON array syntax: ["http://a", "http://b"] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except Exception: + return None + if not isinstance(parsed, list): + return None + items = [str(x).strip() for x in parsed] + return [x for x in items if x] + + # Fallback: comma-separated list: http://a, http://b + items = [part.strip() for part in raw.split(",")] + return [x for x in items if x] + + @dataclass class Settings: DATABASE_URL: str = "sqlite+aiosqlite:///./doc_search.db" UPLOAD_DIR: str = "./uploads" INDEX_DIR: str = "./search_index" CORS_ORIGINS: list[str] = field(default_factory=lambda: ["*"]) + CORS_ALLOW_CREDENTIALS: bool = False + + def __post_init__(self) -> None: + database_url = os.environ.get("DATABASE_URL") + if database_url: + self.DATABASE_URL = database_url + + upload_dir = os.environ.get("UPLOAD_DIR") + if upload_dir: + self.UPLOAD_DIR = upload_dir + + index_dir = os.environ.get("INDEX_DIR") + if index_dir: + self.INDEX_DIR = index_dir + + cors_origins = os.environ.get("CORS_ORIGINS") + if cors_origins: + parsed_origins = _parse_cors_origins(cors_origins) + if parsed_origins is not None: + self.CORS_ORIGINS = parsed_origins + + cors_allow_credentials = os.environ.get("CORS_ALLOW_CREDENTIALS") + if cors_allow_credentials is not None: + parsed_bool = _parse_bool(cors_allow_credentials) + if parsed_bool is not None: + self.CORS_ALLOW_CREDENTIALS = parsed_bool settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index bc6cea8..0ec101a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,10 +24,15 @@ async def lifespan(_: FastAPI): app = FastAPI(title="Doc Search API", lifespan=lifespan) +allow_credentials = settings.CORS_ALLOW_CREDENTIALS +if allow_credentials and "*" in settings.CORS_ORIGINS: + # Wildcard origins cannot be used with credentials. + allow_credentials = False + app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, - allow_credentials=True, + allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 698ab16..e7906cd 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -1,4 +1,9 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.services import search_service router = APIRouter(tags=["health"]) @@ -11,3 +16,16 @@ async def health_check(): @router.get("/api/health") async def api_health_check(): return {"status": "healthy", "version": "1.0.0"} + + +@router.get("/api/health/ready") +async def ready_check(db: AsyncSession = Depends(get_db)): + try: + await db.execute(text("SELECT 1")) + except Exception as exc: + raise HTTPException(status_code=503, detail="Database not ready") from exc + + if not search_service._SEARCH_BACKEND_AVAILABLE: + raise HTTPException(status_code=503, detail="Search backend not ready") + + return {"status": "ready"} diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 2596442..863bcd5 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -1,19 +1,62 @@ -from app.core.config import Settings, settings +import pytest + +from app.core.config import Settings + + +@pytest.fixture(autouse=True) +def clear_settings_env(monkeypatch): + # Ensure deterministic tests regardless of developer machine env. + for key in [ + "DATABASE_URL", + "UPLOAD_DIR", + "INDEX_DIR", + "CORS_ORIGINS", + "CORS_ALLOW_CREDENTIALS", + ]: + monkeypatch.delenv(key, raising=False) def test_settings_loads_with_defaults(): loaded = Settings() assert loaded.DATABASE_URL == "sqlite+aiosqlite:///./doc_search.db" assert loaded.UPLOAD_DIR == "./uploads" + assert loaded.INDEX_DIR == "./search_index" assert loaded.CORS_ORIGINS == ["*"] + assert loaded.CORS_ALLOW_CREDENTIALS is False -def test_database_url_is_set(): - assert isinstance(settings.DATABASE_URL, str) - assert settings.DATABASE_URL +def test_database_url_env_override(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite:///./override.db") + loaded = Settings() + assert loaded.DATABASE_URL == "sqlite+aiosqlite:///./override.db" + +def test_upload_dir_env_override(monkeypatch): + monkeypatch.setenv("UPLOAD_DIR", "./tmp_uploads") + loaded = Settings() + assert loaded.UPLOAD_DIR == "./tmp_uploads" + + +def test_index_dir_env_override(monkeypatch): + monkeypatch.setenv("INDEX_DIR", "./tmp_index") + loaded = Settings() + assert loaded.INDEX_DIR == "./tmp_index" -def test_upload_dir_is_set(): - assert isinstance(settings.UPLOAD_DIR, str) - assert settings.UPLOAD_DIR + +def test_cors_origins_json_array(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["http://localhost:5173", "http://example.com"]') + loaded = Settings() + assert loaded.CORS_ORIGINS == ["http://localhost:5173", "http://example.com"] + + +def test_cors_origins_comma_separated(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "http://a, http://b, ,") + loaded = Settings() + assert loaded.CORS_ORIGINS == ["http://a", "http://b"] + + +def test_cors_allow_credentials_parsing(monkeypatch): + monkeypatch.setenv("CORS_ALLOW_CREDENTIALS", "true") + loaded = Settings() + assert loaded.CORS_ALLOW_CREDENTIALS is True diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 1d53dfb..cfd05b8 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,7 +1,9 @@ import pytest from app.core.config import settings +from app.core.database import get_db from app.main import app, lifespan +from app.services import search_service @pytest.mark.asyncio @@ -18,6 +20,47 @@ async def test_get_api_health_returns_200_and_correct_json(client): assert response.json() == {"status": "healthy", "version": "1.0.0"} +@pytest.mark.asyncio +async def test_get_ready_returns_200_and_correct_json(client, monkeypatch): + monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", True) + response = await client.get("/api/health/ready") + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + + +@pytest.mark.asyncio +async def test_get_ready_returns_503_when_db_not_ready(client, monkeypatch): + monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", True) + + class FailingSession: + async def execute(self, *args, **kwargs): + raise RuntimeError("db not ready") + + async def override_get_db(): + yield FailingSession() + + previous_override = app.dependency_overrides.get(get_db) + app.dependency_overrides[get_db] = override_get_db + try: + response = await client.get("/api/health/ready") + finally: + if previous_override is None: + app.dependency_overrides.pop(get_db, None) + else: + app.dependency_overrides[get_db] = previous_override + + assert response.status_code == 503 + assert response.json()["detail"] == "Database not ready" + + +@pytest.mark.asyncio +async def test_get_ready_returns_503_when_search_backend_not_ready(client, monkeypatch): + monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", False) + response = await client.get("/api/health/ready") + assert response.status_code == 503 + assert response.json()["detail"] == "Search backend not ready" + + @pytest.mark.asyncio async def test_lifespan_creates_upload_dir(tmp_path, monkeypatch): upload_dir = tmp_path / "uploads" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32fd57c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + backend: + image: python:3.11-slim + working_dir: /app/backend + volumes: + - ./backend:/app/backend + - backend-uploads:/app/backend/uploads + - backend-index:/app/backend/search_index + environment: + PYTHONUNBUFFERED: "1" + WATCHFILES_FORCE_POLLING: "true" + ports: + - "8000:8000" + command: > + sh -c "pip install -r requirements.txt && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" + + frontend: + image: node:20-slim + working_dir: /app/frontend + volumes: + - ./frontend:/app/frontend + - frontend-node-modules:/app/frontend/node_modules + environment: + VITE_API_BASE_URL: "http://localhost:8000/api" + CHOKIDAR_USEPOLLING: "true" + ports: + - "5173:5173" + depends_on: + - backend + command: > + sh -c "npm ci && + npm run dev -- --host 0.0.0.0 --port 5173" + +volumes: + frontend-node-modules: + backend-uploads: + backend-index: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..901b60d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Frontend (Vite) +# Optional: defaults to '/api' +# In local dev, Vite proxies '/api' -> http://localhost:8000 +# +# If you run frontend in Docker (see docker-compose.yml), it's convenient to point +# the browser directly at the backend: +# VITE_API_BASE_URL=http://localhost:8000/api diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 66b96cf..0e1e3ab 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -14,6 +14,17 @@ module.exports = { parser: '@typescript-eslint/parser', plugins: ['react', '@typescript-eslint'], settings: { react: { version: 'detect' } }, + overrides: [ + { + files: ['**/*.test.{ts,tsx}', 'src/setupTests.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'testing-library/no-node-access': 'off', + 'testing-library/no-container': 'off', + 'testing-library/no-manual-cleanup': 'off' + } + } + ], rules: { 'react/react-in-jsx-scope': 'off' } diff --git a/frontend/src/components/Search/SearchResults.tsx b/frontend/src/components/Search/SearchResults.tsx index 6c0e06e..10f6603 100644 --- a/frontend/src/components/Search/SearchResults.tsx +++ b/frontend/src/components/Search/SearchResults.tsx @@ -19,7 +19,7 @@ function splitMarkedText(input: string): HighlightPart[] { const parts: HighlightPart[] = []; let rest = input; - while (true) { + for (;;) { const start = rest.indexOf(''); if (start === -1) { if (rest) parts.push({ text: rest, marked: false }); @@ -32,6 +32,7 @@ function splitMarkedText(input: string): HighlightPart[] { const end = rest.indexOf(''); if (end === -1) { + // Unbalanced markup from backend; treat remainder as marked text. if (rest) parts.push({ text: rest, marked: true }); break; } diff --git a/frontend/src/components/Tags/TagManager.tsx b/frontend/src/components/Tags/TagManager.tsx index 3eb8271..9e9bcfc 100644 --- a/frontend/src/components/Tags/TagManager.tsx +++ b/frontend/src/components/Tags/TagManager.tsx @@ -1,5 +1,5 @@ import { Button, Card, Form, Input, Modal, Popconfirm, Space, Table, Tag as AntTag, Typography } from 'antd'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useCreateTag, useDeleteTag, useTags, useUpdateTag } from '../../hooks/useTags'; import type { Tag, TagCreateInput } from '../../types/tag'; @@ -24,11 +24,14 @@ export default function TagManager() { setEditor({ open: true, mode: 'create', initial }); }; - const openEdit = (tag: Tag) => { - const initial: TagCreateInput = { name: tag.name, color: tag.color }; - form.setFieldsValue(initial); - setEditor({ open: true, mode: 'edit', tagId: tag.id, initial }); - }; + const openEdit = useCallback( + (tag: Tag) => { + const initial: TagCreateInput = { name: tag.name, color: tag.color }; + form.setFieldsValue(initial); + setEditor({ open: true, mode: 'edit', tagId: tag.id, initial }); + }, + [form], + ); const columns = useMemo( () => [ @@ -61,7 +64,7 @@ export default function TagManager() { ) } ], - [deleteTag, form], + [deleteTag, openEdit], ); return (