diff --git a/CHANGELOG.md b/CHANGELOG.md index 5493482..1c28c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,39 @@ # Changelog — FlowDeck -## v4.15.5 (2026-09-06) — Correctif test de connexion provider (nvidia) +## v5.0.0 (2026-09-06) — Command Palette & Migrations versionnées + +> Livre deux chantiers du roadmap v5 : la **migration au système de schéma versionné** +> (v5.2.0, priorité n°1 — débloque tout le reste) et la **Command Palette + recherche +> full-text** (v5.0.0). + +### Added — Command palette (v5.0.0) +- **Palette de commandes `Ctrl+K` / `Ctrl+P`** dans `base.html` : modale centrée + autofocusée, navigation clavier (↑/↓/Enter/Esc), surlignage fuzzy des correspondances, + recherche universelle et **quick actions** (Nouvelle page, Library, My Tasks, Trash, + Settings, Help). Le bouton Search de la sidebar et `openQuickFind()` ouvrent désormais + la palette (fini le toast « Quick Find — Ctrl+K »). +- **Endpoint `GET /api/search?q=`** (`app/routers/search.py`) + service + `app/services/search.py` : recherche unifiée des **pages** (titre + contenu) et des + **databases** (nom + description), scopée aux workspaces accessibles de l'utilisateur. +- **Index full-text FTS5** (`pages_fts`, migration v3) avec triggers de synchronisation + (insert/update/delete) sur la table `pages` ; repli automatique sur `LIKE` si FTS5 + absent. Exclusions automatiques des pages supprimées (corbeille). + +### Added — Migrations versionnées (v5.2.0) +- **Runner léger `app/migrations.py`** (alternative à Alembic pour ce projet SQLite) : + table `schema_version` enregistrant la version appliquée ; le schéma de base existant + (`init_db`) devient la **baseline v1**, et chaque changement futur est une étape + versionnée appliquée une seule fois, dans l'ordre. +- **Migration v2 — indexes manquants** : `users(email)`, `user_oauth_tokens(user_id, + provider)`, `collections(workspace_id)`, `pages(workspace_id)`, `pages(deleted_at)`. +- **Migration v3 — FTS5** : `pages_fts` + triggers `pages_fts_ai/ad/au` + backfill. +- `init_db()` termine désormais par `apply_migrations(conn)` : nouveau schéma et bases + existantes convergent sans rejouer les migrations déjà appliquées. + +### Tests +- **267 tests** (8 nouveaux dans `tests/test_search_migrations.py`) : suivi de la version + `schema_version`, indexes créés, table/triggers FTS5, synchronisation de l'index, + recherche pages+collections, exclusions de la corbeille, requête vide et sans résultat. ### Corrections - **Test de connexion multi-provider** : le modèle configuré globalement (ex. `deepseek-v4-flash`) diff --git a/ROADMAP.md b/ROADMAP.md index a07a1b3..767b373 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -416,13 +416,12 @@ app/ - Budget tokens max par conversation (500k tokens) - Timeout 5 minutes par run -### v5.0.0 — Command Palette & Recherche ⬜ (stub uniquement) -> ⚠️ **État actuel** : `Ctrl+K` appelle `openQuickFind()` dans `base.html` qui n'affiche qu'un -> toast « Quick Find — Ctrl+K ». Le raccourci est déjà câblé, la palette elle-même n'existe pas. +### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06) +> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**. -- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier) -- [ ] **Quick actions** — navigation, création, commandes -- [ ] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette) +- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier) +- [x] **Quick actions** — navigation, création, commandes +- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette) ### v5.1.0 — Automations ⬜ (non commencé) @@ -432,9 +431,10 @@ app/ - [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action) - [ ] **Buttons** — boutons cliquables déclenchant des actions -### v5.2.0 — Infrastructure & Polish ⬜ (à prioriser) -> ⚠️ **Priorité recommandée n°1** : les **migrations versionnées** d'abord — la base compte -> déjà 30+ tables créées ad-hoc ; ajouter agent/automations sans versioning va créer une dette ingérable. +### v5.2.0 — Infrastructure & Polish 🔄 (en cours) +> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` + +> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau +> schéma passe désormais par des étapes versionnées (baseline v1, indexes v2, FTS5 v3). > **État actuel** : API publique + tokens **partiellement implémentés** (`routers/public_api.py`, > test `test_public_api_token`) — à compléter dans la section Sécurité. @@ -448,9 +448,9 @@ app/ - [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet) **Infrastructure** -- [ ] **🥇 Migrations versionnées** — Alembic ou table `schema_version` *(URGENT avant v4.10/v5.x)* +- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅ - [ ] **Backup automatique** — cron daily → fichier daté -- [ ] **Index manquants** — `users.email`, `forge_connections.user_id` +- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)` ✅ - [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)* - [ ] **Tests parallèles** — pytest-xdist - [ ] **Build Docker multi-stage** — optimiser taille d'image @@ -461,7 +461,7 @@ app/ - [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter **Tests (cibles)** -- [x] **143 tests** — cible 100+ atteinte ✅ +- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅ - [ ] Tests d'intégration auth (OAuth mock) - [ ] Tests des adapters forge (mock HTTP) - [ ] Tests multi-user (permissions croisées) @@ -599,8 +599,8 @@ app/ ## 🎯 Ordre de priorité recommandé (état 2026-09) -1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste) -2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact +1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`) +2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`) 3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur 4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks) 5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré @@ -622,4 +622,4 @@ Quality DB views, Agent IA Palette → Éditeur bloc Re DB avancée, Wiki-links, Calendrier, AI Templates & lock -*Dernière mise à jour: 2026-09-05 — v4.10.0 Agent IA livré (18 tests) ; audit précédent : v5.10.0 → v5.14.0 ajoutées (interactions de bloc, wiki-links & mentions, templates & page lock, realtime, synced blocks)* +*Dernière mise à jour: 2026-09-06 — v5.0.0 Command Palette + FTS5 et v5.2.0 Migrations versionnées (schema_version) + indexes livrés (267 tests) ; reste v5.3 → v6.0* diff --git a/VERSION b/VERSION index 5c56ff0..28cbf7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.5 \ No newline at end of file +5.0.0 \ No newline at end of file diff --git a/app/db.py b/app/db.py index dbab895..a0fff76 100644 --- a/app/db.py +++ b/app/db.py @@ -813,6 +813,10 @@ def init_db(): conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)") conn.commit() + # ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ── + from app.migrations import apply_migrations + apply_migrations(conn) + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index 10e6af4..0cf3be2 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search from app.routers.notifications import router as notifications_router from app.routers.collaboration import router as collaboration_router from app.routers.gitea import router as gitea_router @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.15.5", + version="5.0.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -92,6 +92,7 @@ app.include_router(export.router) app.include_router(notifications_router) app.include_router(collaboration_router) app.include_router(agent.router) +app.include_router(search.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/migrations.py b/app/migrations.py new file mode 100644 index 0000000..19aca77 --- /dev/null +++ b/app/migrations.py @@ -0,0 +1,172 @@ +"""FlowDeck — versioned schema migrations (lightweight, no Alembic). + +This replaces the previous "ad-hoc" approach where every new schema change was +appended directly to `app/db.py::init_db()` with no tracking. A `schema_version` +table now records the highest applied migration; the full baseline schema +(created idempotently by `init_db`) is treated as version 1, and any incremental +change is expressed as an ordered, versioned step below and applied exactly once. + +Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys +already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded +``ALTER TABLE``) so it is safe even if partially re-run. +""" +from __future__ import annotations + +import logging +import sqlite3 +from typing import Callable, List, Tuple + +logger = logging.getLogger(__name__) + +# The full baseline schema created by `app.db::init_db()` is "version 1". +BASELINE_VERSION = 1 + +# (version, name, apply_fn). Kept sorted by version at registration time. +MIGRATIONS: List[Tuple[int, str, Callable[[sqlite3.Connection], None]]] = [] + + +def register(version: int, name: str) -> Callable: + """Decorator registering a migration in the ordered registry.""" + if any(v == version for v, _, _ in MIGRATIONS): + raise ValueError(f"Duplicate migration version {version}") + + def decorator(fn: Callable[[sqlite3.Connection], None]): + MIGRATIONS.append((version, name, fn)) + MIGRATIONS.sort(key=lambda item: item[0]) + return fn + + return decorator + + +def _ensure_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def current_version(conn: sqlite3.Connection) -> int: + _ensure_table(conn) + row = conn.execute( + "SELECT COALESCE(MAX(version), 0) AS v FROM schema_version" + ).fetchone() + return int(row[0]) + + +def fts5_available() -> bool: + """True when the bundled SQLite ships the FTS5 extension.""" + probe = sqlite3.connect(":memory:") + try: + probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)") + return True + except sqlite3.OperationalError: + return False + finally: + probe.close() + + +def apply_migrations(conn: sqlite3.Connection) -> int: + """Seal the baseline schema (version 1) and apply pending migrations. + + Returns the resulting schema version. + """ + _ensure_table(conn) + applied = current_version(conn) + + if applied < BASELINE_VERSION: + # The pre-existing schema (already created by init_db) is our baseline. + conn.execute( + "INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)", + (BASELINE_VERSION, "baseline"), + ) + conn.commit() + applied = BASELINE_VERSION + + for version, name, fn in MIGRATIONS: + if version <= applied: + continue + fn(conn) + conn.execute( + "INSERT INTO schema_version (version, name) VALUES (?, ?)", + (version, name), + ) + conn.commit() + applied = version + logger.info("Applied migration %d: %s", version, name) + + return applied + + +# ═══════════════════════════════════════════════════════════════════════════ +# Migrations +# ═══════════════════════════════════════════════════════════════════════════ + + +@register(2, "missing indexes") +def _migration_missing_indexes(conn: sqlite3.Connection) -> None: + """Add the indexes flagged in the roadmap (fast lookups by email, forge user).""" + for ddl in ( + "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)", + "CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)", + "CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)", + "CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)", + "CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)", + ): + conn.execute(ddl) + + +@register(3, "full-text search (FTS5)") +def _migration_fts5(conn: sqlite3.Connection) -> None: + """Create a full-text index over pages (title + content) for the command palette. + + Kept in sync via row-level triggers on the ``pages`` table so page + insert/update/delete are reflected immediately. Skips gracefully if the + bundled SQLite lacks FTS5 (search then falls back to LIKE). + """ + if not fts5_available(): + logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)") + return + + conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)") + + conn.execute( + """ + CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN + INSERT INTO pages_fts(rowid, title, body) + VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, '')); + END + """ + ) + # `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes + # use a plain DELETE by rowid (NOT the special 'delete' insert that only applies + # to external-content/contentless FTS5 tables). + conn.execute( + """ + CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN + DELETE FROM pages_fts WHERE rowid = old.id; + END + """ + ) + conn.execute( + """ + CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN + DELETE FROM pages_fts WHERE rowid = old.id; + INSERT INTO pages_fts(rowid, title, body) + VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, '')); + END + """ + ) + + # Backfill the index from any rows that already exist. + conn.execute( + """ + INSERT INTO pages_fts(rowid, title, body) + SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages + WHERE deleted_at IS NULL + """ + ) \ No newline at end of file diff --git a/app/routers/search.py b/app/routers/search.py new file mode 100644 index 0000000..4215825 --- /dev/null +++ b/app/routers/search.py @@ -0,0 +1,27 @@ +"""FlowDeck — unified search router (v5.0.0). + +``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching +editor pages and databases scoped to the current user's accessible workspaces. +""" +from __future__ import annotations + +from fastapi import APIRouter, Request, Query + +from app.auth.session import SessionManager +from app.services.search import search as search_service + +router = APIRouter(tags=["search"]) + + +@router.get("/api/search") +async def search(request: Request, q: str = Query(default="")): + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + user_id = user.get("id") if user and user.get("id") else None + + data = search_service(q, user_id=user_id) + return { + "query": q, + "pages": data["pages"], + "collections": data["collections"], + "total": len(data["pages"]) + len(data["collections"]), + } \ No newline at end of file diff --git a/app/services/search.py b/app/services/search.py new file mode 100644 index 0000000..876ae8a --- /dev/null +++ b/app/services/search.py @@ -0,0 +1,187 @@ +"""FlowDeck — unified search (v5.0.0). + +Powers the Ctrl+K command palette. Searches editor pages and databases +(collections) with SQLite FTS5 when available, falling back to ``LIKE`` scans +otherwise. Results are scoped to the workspaces the current user can access. +""" +from __future__ import annotations + +import logging +import re +from typing import Optional + +from app.db import get_conn +from app.migrations import fts5_available + +logger = logging.getLogger(__name__) + + +# ── FTS5 helpers ──────────────────────────────────────────────────────────── + +def _fts_terms(query: str) -> list[str]: + """Split a user query into safe FTS5 prefix terms.""" + tokens = re.findall(r"[\wÀ-ÿ]+", query, flags=re.UNICODE) + return [t.replace('"', '""') for t in tokens if t] + + +def _fts_match(query: str) -> Optional[str]: + """Build a MATCH expression, or None when the query is not FTS-safe.""" + terms = _fts_terms(query) + if not terms: + return None + return " AND ".join(f'"{t}"*' for t in terms) + + +def _has_fts_table(conn) -> bool: + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'" + ).fetchone() + return row is not None + except Exception: + return False + + +def _extract_plain_text(content: str, content_format: str) -> str: + """Return a readable one-line excerpt for a page raw ``content`` value.""" + if not content: + return "" + fmt = content_format or "blocks" + if fmt == "blocks": + try: + import json as _json + blocks = _json.loads(content) + parts = [] + for b in blocks if isinstance(blocks, list) else []: + if isinstance(b, dict): + text = b.get("content") or b.get("text") or "" + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + for child in (b.get("children") or []): + if isinstance(child, dict) and (child.get("content") or child.get("text")): + parts.append(str(child.get("content") or child.get("text")).strip()) + return " ".join(parts) + except Exception: + return content + if fmt == "file": + return "" + return content + + +def _workspace_name(conn, workspace_id) -> str: + if not workspace_id: + return "" + try: + row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone() + return row["name"] if row else "" + except Exception: + return "" + + +def _scope_where(user_id: Optional[int]) -> tuple[str, list]: + """SQL filter restricting results to the user's accessible workspaces.""" + if user_id is None: + return "1=1", [] + return ( + "(workspace_id IS NULL OR workspace_id IN (" + " SELECT id FROM workspaces WHERE owner_id = ? " + " UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?))", + [user_id, user_id], + ) + + +# ── Search entry point ────────────────────────────────────────────────────── + +def search(query: str, user_id: Optional[int] = None, limit: int = 20) -> dict: + """Return unified search results: ``{pages: [...], collections: [...]}``.""" + q = (query or "").strip() + if not q: + return {"pages": [], "collections": []} + + with get_conn() as conn: + pages = _search_pages(conn, q, user_id, limit) + collections = _search_collections(conn, q, user_id, limit) + return {"pages": pages, "collections": collections} + + +def _search_pages(conn, query: str, user_id: Optional[int], limit: int) -> list: + like = f"%{query}%" + scope, params = _scope_where(user_id) + + # 1) FTS5 fast path. + if fts5_available() and _has_fts_table(conn): + match = _fts_match(query) + if match: + try: + rows = conn.execute( + f""" + SELECT p.id, p.title, p.content, p.content_format, + p.workspace_id, p.content_format + FROM pages_fts f + JOIN pages p ON p.id = f.rowid + WHERE pages_fts MATCH ? AND p.deleted_at IS NULL AND {scope} + ORDER BY rank LIMIT ? + """, + [match, *params, limit], + ).fetchall() + return _page_rows_to_results(conn, rows) + except Exception as exc: # FTS syntax/edge case → fall through to LIKE + logger.debug("FTS search failed (%s); fallback to LIKE", exc) + + # 2) LIKE fallback. + rows = conn.execute( + f""" + SELECT p.id, p.title, p.content, p.content_format, p.workspace_id + FROM pages p + WHERE p.deleted_at IS NULL AND {scope} + AND (p.title LIKE ? OR p.content LIKE ?) + ORDER BY p.updated_at DESC LIMIT ? + """, + [*params, like, like, limit], + ).fetchall() + return _page_rows_to_results(conn, rows) + + +def _search_collections(conn, query: str, user_id: Optional[int], limit: int) -> list: + like = f"%{query}%" + scope, params = _scope_where(user_id) + rows = conn.execute( + f""" + SELECT c.id, c.name, c.description, c.icon, c.workspace_id + FROM collections c + WHERE {scope} + AND (c.name LIKE ? OR c.description LIKE ?) + ORDER BY c.updated_at DESC LIMIT ? + """, + [*params, like, like, limit], + ).fetchall() + return [ + { + "id": r["id"], + "type": "collection", + "title": r["name"] or "Untitled", + "subtitle": "Database" + (f" · {_workspace_name(conn, r['workspace_id'])}" if r["workspace_id"] else ""), + "icon": (r["icon"] or "📋"), + "url": f"/db/{r['id']}", + } + for r in rows + ] + + +def _page_rows_to_results(conn, rows) -> list: + results = [] + for r in rows: + title = (r["title"] or "Untitled").strip() or "Untitled" + ws = _workspace_name(conn, r["workspace_id"]) + subtitle = ws or "Page" + excerpt = _extract_plain_text(r["content"], r["content_format"]) + results.append({ + "id": r["id"], + "type": "page", + "title": title, + "subtitle": subtitle, + "icon": "file", + "excerpt": excerpt[:160], + "url": f"/pages/{r['id']}", + }) + return results \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 11ac08a..99b8367 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,7 +5,7 @@ FlowDeck — {% block title_prefix %}Home{% endblock %} - + +
+ +
+ + diff --git a/tests/test_search_migrations.py b/tests/test_search_migrations.py new file mode 100644 index 0000000..20375d1 --- /dev/null +++ b/tests/test_search_migrations.py @@ -0,0 +1,172 @@ +"""FlowDeck — v5.2.0 (versioned migrations) & v5.0.0 (search / command palette API). + +Covers the ``schema_version`` runner, the FTS5 full-text index (with triggers), +and the ``GET /api/search`` endpoint powering the Ctrl+K command palette. +""" +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + + from app.main import app + from app.db import init_db + init_db() + + yield TestClient(app) + + os.unlink(db_path) + + +def _create_page(title, content="", content_format="blocks"): + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section) " + "VALUES ('', ?, ?, ?, 'Private')", + (title, content, content_format), + ) + conn.commit() + return cur.lastrowid + + +def _create_collection(name, description="", icon="📋"): + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO collections (name, description, icon, schema_json) " + "VALUES (?, ?, ?, '[]')", + (name, description, icon), + ) + conn.commit() + return cur.lastrowid + + +# ── v5.2.0: versioned migrations ── + +def test_schema_version_table_tracks_migrations(client): + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT version, name FROM schema_version ORDER BY version" + ).fetchall() + versions = [r["version"] for r in rows] + assert 1 in versions # baseline + assert max(versions) >= 2 # versioned migrations applied + + +def test_missing_indexes_created(client): + from app.db import get_conn + with get_conn() as conn: + idx = {r["name"] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='index'" + ).fetchall()} + assert "idx_users_email" in idx + assert "idx_user_oauth_tokens_user" in idx + + +def test_fts5_virtual_table_and_triggers(client): + from app.db import get_conn + from app.migrations import fts5_available + if not fts5_available(): + pytest.skip("FTS5 not available in this SQLite build") + with get_conn() as conn: + tbl = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'" + ).fetchone() + assert tbl is not None + trigs = {r["name"] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='trigger'" + ).fetchall()} + assert {"pages_fts_ai", "pages_fts_ad", "pages_fts_au"} <= trigs + + +def test_fts_index_stays_in_sync_with_pages(client): + from app.db import get_conn + from app.migrations import fts5_available + if not fts5_available(): + pytest.skip("FTS5 not available in this SQLite build") + + pid = _create_page("SyncCheck", "alpha beta gamma") + with get_conn() as conn: + hits = conn.execute( + "SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'" + ).fetchone()["c"] + assert hits == 1 + # updating a page refreshes the index + conn.execute("UPDATE pages SET title='SyncCheck2', content='delta epsilon' WHERE id=?", (pid,)) + conn.commit() + with get_conn() as conn: + hits = conn.execute( + "SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'" + ).fetchone()["c"] + assert hits == 1 + old = conn.execute( + "SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'" + ).fetchone()["c"] + assert old == 0 + # deleting a page removes it from the index + conn.execute("DELETE FROM pages WHERE id=?", (pid,)) + conn.commit() + with get_conn() as conn: + gone = conn.execute( + "SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'" + ).fetchone()["c"] + assert gone == 0 + + +# ── v5.0.0: search API ── + +def test_search_returns_pages_and_collections(client): + _create_page("Roadmap 2026", '{"blocks":[{"type":"paragraph","content":"build the future"}]}') + _create_collection("Projects Database", "track our roadmap projects", icon="🚀") + + resp = client.get("/api/search", params={"q": "roadmap"}) + assert resp.status_code == 200 + data = resp.json() + + titles = [p["title"] for p in data["pages"]] + [c["title"] for c in data["collections"]] + assert any("Roadmap 2026" in t for t in titles) + assert any("Projects Database" in t for t in titles) + + +def test_search_empty_query_returns_no_results(client): + _create_page("Anything") + resp = client.get("/api/search", params={"q": ""}) + assert resp.status_code == 200 + data = resp.json() + assert data["pages"] == [] + assert data["collections"] == [] + + +def test_search_excludes_deleted_pages(client): + from app.db import get_conn + pid = _create_page("SecretDraft", "very private content") + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,)) + conn.commit() + + resp = client.get("/api/search", params={"q": "SecretDraft"}) + assert resp.status_code == 200 + assert not [p for p in resp.json()["pages"] if p["id"] == pid] + + +def test_search_no_match_returns_empty(client): + _create_page("Alpha") + resp = client.get("/api/search", params={"q": "zzzz_nonexistent_qqqq"}) + assert resp.status_code == 200 + data = resp.json() + assert data["pages"] == [] + assert data["collections"] == [] + assert data["total"] == 0 \ No newline at end of file