feat(admin): local directory sources — kind/path on git_sources, combined sync + import, page form + badges
An existing, non-git directory is now a first-class source alongside
the git repos: one table (git_sources + kind discriminator — A13
reversible migration), one admin page, one Sync button (phase locked
decisions; the phase-35 table is extended, not duplicated). The DB is
the local-source registry — no env var for local paths;
BOR_GIT_SOURCES stays a git-only empty-table fallback.
Migration 0007 (reversible, up/down integration-tested):
git_sources.kind TEXT NOT NULL DEFAULT 'git' + ck_git_sources_kind
(kind IN ('git','local')); git_sources.path TEXT NULL +
uq_git_sources_path (mirrors 0006's uq_git_sources_url). Existing rows
read kind='git', path=NULL.
API (phase-35 contract extended, git byte-identical): POST kind=local
requires path — trimmed, ~-expanded, absolute + an existing server
directory, else 422 naming the path (fail loud at add-time); duplicate
path 409 (named); wrong field combos 422. GET rows carry kind + path
(git and env rows: path null); anonymous still 403 on every route (A10).
Sync + import_docs resolve DB git + local rows together: git →
clone_or_pull (unchanged); local → re-verified .is_dir() AT SYNC TIME
(it may have moved/deleted since add-time) — a missing dir raises
"local source missing: <path>" (sanitized) before anything imports;
one import_sources(..., prune=True) over the single combined list
(pruning covers the union). Both-empty fails loudly ("no sources
configured (git or local)"); --source still wins; the env fallback
stays git-only.
Page: second "Add a local directory" form (the same §7.4 never-stale
button + inline-error lifecycle as the git form; 422/409 details name
the path), Git/Local badges on rows (text + color, never color alone —
WCAG), updated hint (git + local together, union prune); the
anonymous sign-in gate is unchanged.
Tests: 0007 up/down; the API local-kind matrix (403/201/422/409) with
the git-kind suite green unchanged; the sync pipeline local/git/
mixed/missing against a host temp dir (the KB actually updated);
import_docs DB resolution + --source precedence. Story E2E (isolated,
deterministic across runs): add (Local badge) → missing path inline
422 naming it / duplicate 409 → the real Sync button imports the
fixture file (GET /api/docs + sentinel in its content) → file deleted
+ sync prunes it (union prune) → row removed; anonymous gate + 403s
(phase-35 regression). test_git_sources_admin.py (phase 35) green
UNCHANGED — no selector collision with the new form;
test_sync_button.py green.
Docs: README — the two managed kinds (git = clone/pull mirror; local =
direct in-place walk), add-time validation, union pruning, "the DB is
the local-source registry (no env var for local paths)";
.env.example — the env fallback is git-only.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Integration: the admin git-sources CRUD API (phase 35, task 02).
|
||||
"""Integration: the admin sources CRUD API (phase 35, task 02; local
|
||||
kind, phase 38, task 02).
|
||||
|
||||
Real Postgres (``podman compose up -d db``); the ``BOR_GIT_SOURCES``
|
||||
fallback is exercised deterministically by monkeypatching the router's
|
||||
@@ -7,17 +8,25 @@ fallback is exercised deterministically by monkeypatching the router's
|
||||
|
||||
Contract under test:
|
||||
|
||||
* anonymous → 403 ``{"detail": "admin only"}`` on GET, POST, and DELETE
|
||||
(phase 16 pattern, same as ``/api/sync``);
|
||||
* GET — empty table + env set → the env rows with ``from_env: true`` and
|
||||
null ``id``/``added_at``; empty table + empty env → ``sources: []``
|
||||
with ``from_env: true``; any DB rows → ``from_env: false`` and the env
|
||||
var is ignored (the phase's locked decision); DB rows ordered by
|
||||
``(added_at, id)``;
|
||||
* POST — 201 stored trimmed; duplicate (even with different surrounding
|
||||
whitespace) → 409 with a generic detail that never echoes the URL
|
||||
(credential safety), including when only the DB unique index catches
|
||||
it; bad shape / blank / >500 chars → 422, also input-free;
|
||||
* anonymous → 403 ``{"detail": "admin only"}`` on GET, POST (git and
|
||||
local), and DELETE (phase 16 pattern, same as ``/api/sync``);
|
||||
* GET — rows carry ``kind`` + ``path`` (phase 38); empty table + env
|
||||
set → the git-only env rows (``kind: "git"``, ``path: null``) with
|
||||
``from_env: true`` and null ``id``/``added_at``; empty table + empty
|
||||
env → ``sources: []`` with ``from_env: true``; any DB rows →
|
||||
``from_env: false`` and the env var is ignored (the phase's locked
|
||||
decision); DB rows ordered by ``(added_at, id)``;
|
||||
* POST ``kind=git`` (default) — 201 stored trimmed; duplicate (even with
|
||||
different surrounding whitespace) → 409 with a generic detail that
|
||||
never echoes the URL (credential safety), including when only the DB
|
||||
unique index catches it; bad shape / blank / >500 chars → 422, also
|
||||
input-free (the phase-35 contract, unchanged);
|
||||
* POST ``kind=local`` (phase 38) — existing directory → 201, stored row
|
||||
carries ``kind=local`` + the path expanded (``~`` resolved) and
|
||||
trimmed; relative / missing / not-a-directory path → 422 naming the
|
||||
path (not a secret); duplicate path → 409 naming the path (unique
|
||||
index as backstop); wrong field combinations (git without url, local
|
||||
without path, both kinds' fields) → 422;
|
||||
* DELETE — 204 and gone; an emptied table falls back to the env list
|
||||
again; unknown id → 404.
|
||||
|
||||
@@ -28,6 +37,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -66,6 +76,10 @@ def test_anonymous_gets_403_on_all_routes(client: TestClient, db: Session) -> No
|
||||
r = client.post("/api/git-sources", json={"url": "https://anon.example.com/x.git"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
# The phase-38 local kind is gated the same way.
|
||||
r = client.post("/api/git-sources", json={"kind": "local", "path": "/tmp"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
@@ -89,10 +103,23 @@ def test_get_empty_table_with_env_returns_env_rows(
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["from_env"] is True
|
||||
# Whitespace-trimmed, empty entries dropped, order preserved; null ids.
|
||||
# Whitespace-trimmed, empty entries dropped, order preserved; null
|
||||
# ids; the env fallback is git-only (phase 38: kind + path fields).
|
||||
assert body["sources"] == [
|
||||
{"id": None, "url": "https://a.example.com/one.git", "added_at": None},
|
||||
{"id": None, "url": "git@b.example.com:two.git", "added_at": None},
|
||||
{
|
||||
"id": None,
|
||||
"kind": "git",
|
||||
"url": "https://a.example.com/one.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
},
|
||||
{
|
||||
"id": None,
|
||||
"kind": "git",
|
||||
"url": "git@b.example.com:two.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -248,6 +275,200 @@ def test_post_rejects_blank_and_oversized_urls(admin_client: TestClient, db: Ses
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 1
|
||||
|
||||
|
||||
# --- POST: local kind (phase 38, task 02) ----------------------------------
|
||||
|
||||
|
||||
def test_post_local_creates_stored_row_with_expanded_path(
|
||||
admin_client: TestClient,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``kind=local`` + an existing directory → 201; the stored row
|
||||
carries ``kind=local`` and the path expanded (``~`` resolved via the
|
||||
server's ``HOME``, whitespace trimmed)."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
real_dir = tmp_path / "home" / "notes"
|
||||
real_dir.mkdir(parents=True)
|
||||
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": " ~/notes\t"})
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
# The phase-35 response shape is unchanged — the local row reports
|
||||
# its (expanded) path in ``url``; ``kind`` + ``path`` via GET.
|
||||
assert set(body) == {"id", "url", "added_at"}
|
||||
uuid.UUID(body["id"])
|
||||
assert body["url"] == str(real_dir)
|
||||
assert body["added_at"] is not None
|
||||
|
||||
row = admin_client.get("/api/git-sources").json()["sources"][0]
|
||||
assert row["kind"] == "local"
|
||||
assert row["path"] == str(real_dir)
|
||||
assert row["url"] == str(real_dir)
|
||||
assert row["id"] is not None
|
||||
assert row["added_at"] is not None
|
||||
|
||||
|
||||
def test_post_local_stores_trimmed_normalized_path(
|
||||
admin_client: TestClient, tmp_path: Path
|
||||
) -> None:
|
||||
"""Absolute path with surrounding whitespace + trailing slash →
|
||||
stored clean (trimmed, normalized)."""
|
||||
real_dir = tmp_path / "plain"
|
||||
real_dir.mkdir()
|
||||
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": f" {real_dir}/ "})
|
||||
assert r.status_code == 201, r.text
|
||||
row = admin_client.get("/api/git-sources").json()["sources"][0]
|
||||
assert row["path"] == str(real_dir)
|
||||
|
||||
|
||||
def test_post_local_relative_path_returns_422_naming_path(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A relative path fails loud at add-time — 422 naming the path
|
||||
(relative or not, it is never stored)."""
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": "relative/dir"})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "local source path is not a directory: relative/dir"
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_post_local_missing_path_returns_422_naming_path(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A missing (absolute) path is a user error → 422 naming the path so
|
||||
the owner sees exactly which directory failed."""
|
||||
missing = f"/nonexistent/bor-test-{uuid.uuid4()}"
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": missing})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == f"local source path is not a directory: {missing}"
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_post_local_file_not_dir_returns_422(
|
||||
admin_client: TestClient, tmp_path: Path
|
||||
) -> None:
|
||||
"""An existing *file* is not a directory → 422 naming the path."""
|
||||
a_file = tmp_path / "a-file.md"
|
||||
a_file.write_text("not a directory")
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": str(a_file)})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == f"local source path is not a directory: {a_file}"
|
||||
|
||||
|
||||
def test_post_local_duplicate_path_returns_409_naming_path(
|
||||
admin_client: TestClient, tmp_path: Path
|
||||
) -> None:
|
||||
"""Duplicate path (even with different surrounding whitespace) → 409
|
||||
naming the path (a path is not a secret, unlike a git URL)."""
|
||||
real_dir = tmp_path / "dups"
|
||||
real_dir.mkdir()
|
||||
|
||||
assert admin_client.post("/api/git-sources", json={"kind": "local", "path": str(real_dir)})
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": f" {real_dir}\t"})
|
||||
assert r.status_code == 409
|
||||
detail = r.json()["detail"]
|
||||
assert detail == f"a local source with this path already exists: {real_dir}"
|
||||
# Exactly one row stored.
|
||||
assert len(admin_client.get("/api/git-sources").json()["sources"]) == 1
|
||||
|
||||
|
||||
def test_post_local_concurrent_insert_backstop_still_409(
|
||||
admin_client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If the duplicate pre-check misses (a concurrent insert lands
|
||||
between the check and the commit), the DB unique index on ``path``
|
||||
still yields the 409 naming the path — never a 500."""
|
||||
real_dir = tmp_path / "backstop"
|
||||
real_dir.mkdir()
|
||||
assert (
|
||||
admin_client.post("/api/git-sources", json={"kind": "local", "path": str(real_dir)})
|
||||
.status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
real_select = git_sources_api.select
|
||||
|
||||
def blind_select(*args: Any, **kwargs: Any) -> Any:
|
||||
if args and args[0] is GitSource: # the duplicate pre-check
|
||||
# …now never matches — only the unique index can catch it.
|
||||
return real_select(GitSource).where(GitSource.url == "zz-never-matches")
|
||||
return real_select(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(git_sources_api, "select", blind_select)
|
||||
r = admin_client.post("/api/git-sources", json={"kind": "local", "path": str(real_dir)})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == f"a local source with this path already exists: {real_dir}"
|
||||
|
||||
|
||||
def test_post_wrong_field_combinations_return_422(
|
||||
admin_client: TestClient, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""git without url, local without path, and both kinds' fields are
|
||||
422 with fixed details — nothing is stored."""
|
||||
real_dir = tmp_path / "combo"
|
||||
real_dir.mkdir()
|
||||
|
||||
assert admin_client.post("/api/git-sources", json={"kind": "git"}).status_code == 422
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"kind": "git", "url": "https://example.com/both.git", "path": str(real_dir)},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "a git source takes a url, not a path"
|
||||
assert admin_client.post("/api/git-sources", json={"kind": "local"}).status_code == 422
|
||||
# Whitespace-only path trims to empty → 422 as well (the schema's
|
||||
# min-length guard).
|
||||
assert (
|
||||
admin_client.post("/api/git-sources", json={"kind": "local", "path": " "}).status_code
|
||||
== 422
|
||||
)
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"kind": "local", "url": "https://example.com/both.git", "path": str(real_dir)},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "a local source takes a path, not a url"
|
||||
# Unknown kind and an oversized path are 422 too.
|
||||
assert (
|
||||
admin_client.post(
|
||||
"/api/git-sources", json={"kind": "svn", "url": "https://example.com/x.git"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
admin_client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": "/" + "x" * 2000}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_get_mixed_kinds_in_added_order(
|
||||
admin_client: TestClient, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""GET mixes git + local rows in ``(added_at, id)`` order; git rows
|
||||
report ``path: null``, local rows their stored path."""
|
||||
git_url = "https://example.com/mixed.git"
|
||||
db.add(GitSource(url=git_url, kind="git", added_at=datetime.now(UTC) - timedelta(hours=1)))
|
||||
db.commit()
|
||||
real_dir = tmp_path / "mixed"
|
||||
real_dir.mkdir()
|
||||
assert admin_client.post("/api/git-sources", json={"kind": "local", "path": str(real_dir)})
|
||||
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
assert body["from_env"] is False
|
||||
assert [s["url"] for s in body["sources"]] == [git_url, str(real_dir)]
|
||||
git_row, local_row = body["sources"]
|
||||
assert git_row["kind"] == "git"
|
||||
assert git_row["path"] is None
|
||||
assert git_row["id"] is not None
|
||||
assert local_row["kind"] == "local"
|
||||
assert local_row["path"] == str(real_dir)
|
||||
assert local_row["id"] is not None
|
||||
|
||||
|
||||
# --- DB rows win over env ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -284,11 +505,18 @@ def test_delete_removes_row_and_falls_back_to_env(
|
||||
|
||||
assert admin_client.delete(f"/api/git-sources/{created.json()['id']}").status_code == 204
|
||||
|
||||
# The table is empty again → the env fallback is live once more.
|
||||
# The table is empty again → the env fallback is live once more
|
||||
# (git-only rows, phase 38).
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
assert body["from_env"] is True
|
||||
assert body["sources"] == [
|
||||
{"id": None, "url": "https://env.example.com/env.git", "added_at": None}
|
||||
{
|
||||
"id": None,
|
||||
"kind": "git",
|
||||
"url": "https://env.example.com/env.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
"""Integration test: ``import_docs`` git-source resolution (phase 28, task 03).
|
||||
"""Integration test: ``import_docs`` source resolution (phase 28, task
|
||||
03; phase 35 re-points at the shared resolver; phase 38 adds the local
|
||||
kind).
|
||||
|
||||
Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull`` (no
|
||||
real git, no network) and a recording fake ``import_sources`` (no real
|
||||
DB), covering:
|
||||
Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull``
|
||||
(no real git, no network) and a recording fake ``import_sources`` (no
|
||||
real DB), covering:
|
||||
|
||||
- Effective git sources set (phase 35: the shared resolver — stubbed
|
||||
here, keeping this file's no-real-DB style) → each URL is cloned/pulled
|
||||
into ``BOR_SOURCES_DIR/<repo-name>/`` and exactly those dirs are
|
||||
imported.
|
||||
- DB rows win over ``BOR_GIT_SOURCES`` (the resolver's ``db`` origin —
|
||||
the env list is ignored).
|
||||
- ``--source`` still wins over git sources (no git at all, no resolver
|
||||
- Effective sources set (phase 35: the shared resolver — stubbed here,
|
||||
keeping this file's no-real-DB style) → each git URL is cloned/pulled
|
||||
into ``BOR_SOURCES_DIR/<repo-name>/``; local rows are their existing
|
||||
directories, walked directly; exactly those dirs are imported.
|
||||
- DB rows (both kinds) win over ``BOR_GIT_SOURCES`` (the resolver's
|
||||
``db`` origin — the env list is ignored; the env fallback stays
|
||||
git-only).
|
||||
- ``--source`` still wins over the DB rows (no git at all, no resolver
|
||||
call).
|
||||
- No git sources + no ``--source`` → the legacy ``DEFAULT_SOURCES``.
|
||||
- No sources configured + no ``--source`` → the legacy
|
||||
``DEFAULT_SOURCES``.
|
||||
- A failing git sync → exit code 1, an error naming the failing repo on
|
||||
stderr, and **zero** import attempts.
|
||||
- A missing local directory → the same pre-import fail-loud: exit code
|
||||
1, ``local source missing: <path>`` on stderr, zero import attempts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import GitSource
|
||||
from app.rag.importer import ImportSummary
|
||||
from scripts import import_docs
|
||||
from scripts.git_sync import GitSyncError
|
||||
@@ -33,6 +41,16 @@ def _settings(git_sources: str = "", sources_dir: str = "~/bor-sources") -> Sett
|
||||
return Settings(_env_file=None, git_sources=git_sources, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _git_row(url: str) -> GitSource:
|
||||
return GitSource(url=url, kind="git")
|
||||
|
||||
|
||||
def _local_row(path: str) -> GitSource:
|
||||
"""A local row as the phase-38 API stores it: the expanded path in
|
||||
both ``path`` and the NOT-NULL ``url`` location column."""
|
||||
return GitSource(url=path, kind="local", path=path)
|
||||
|
||||
|
||||
class FakeImportSources:
|
||||
"""Records every ``import_sources`` call instead of touching a DB."""
|
||||
|
||||
@@ -97,11 +115,15 @@ def test_resolve_sources_git_urls_cloned_into_sources_dir(
|
||||
calls, fake = _fake_clone_factory()
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
# Phase 35: resolution goes through the shared resolver (stubbed —
|
||||
# this file keeps its no-real-DB style); the URLs are the env list.
|
||||
# this file keeps its no-real-DB style); the rows are the env list,
|
||||
# surfaced as synthetic git rows.
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_git_sources",
|
||||
lambda db: (["https://host/a/homelab.git", "git@host:user/deploy.git"], "env"),
|
||||
"effective_sources",
|
||||
lambda db: (
|
||||
[_git_row("https://host/a/homelab.git"), _git_row("git@host:user/deploy.git")],
|
||||
"env",
|
||||
),
|
||||
)
|
||||
settings = _settings(
|
||||
git_sources="https://host/a/homelab.git, git@host:user/deploy.git ,",
|
||||
@@ -140,8 +162,8 @@ def test_resolve_sources_db_rows_win_over_env(
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_git_sources",
|
||||
lambda db: (["https://db.example/only.git"], "db"),
|
||||
"effective_sources",
|
||||
lambda db: ([_git_row("https://db.example/only.git")], "db"),
|
||||
)
|
||||
settings = _settings(
|
||||
git_sources="https://env.example/ignored.git",
|
||||
@@ -158,7 +180,7 @@ def test_resolve_sources_defaults_when_nothing_configured(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Both origins empty (the resolver's ``([], "env")``) → legacy dirs.
|
||||
monkeypatch.setattr(import_docs, "effective_git_sources", lambda db: ([], "env"))
|
||||
monkeypatch.setattr(import_docs, "effective_sources", lambda db: ([], "env"))
|
||||
sources = import_docs._resolve_sources(None, _settings())
|
||||
assert sources == [p.expanduser() for p in import_docs.DEFAULT_SOURCES]
|
||||
|
||||
@@ -178,8 +200,11 @@ def test_main_git_sources_clone_then_import(
|
||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_git_sources",
|
||||
lambda db: (["https://host/a/homelab.git", "https://host/a/deploy.git"], "env"),
|
||||
"effective_sources",
|
||||
lambda db: (
|
||||
[_git_row("https://host/a/homelab.git"), _git_row("https://host/a/deploy.git")],
|
||||
"env",
|
||||
),
|
||||
)
|
||||
calls, fake = _fake_clone_factory()
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
@@ -227,6 +252,55 @@ def test_main_cli_source_still_imports_manual_dir(
|
||||
assert fake_import.calls[0]["prune"] is False
|
||||
|
||||
|
||||
def test_resolve_sources_mixed_git_and_local(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 38: DB rows of both kinds — the git row is cloned into
|
||||
``BOR_SOURCES_DIR``, the local row is its existing directory itself
|
||||
(no clone), in row order; the env list is ignored."""
|
||||
calls, fake = _fake_clone_factory()
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "a.md").write_text("# A\nlocal fixture\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: (
|
||||
[_git_row("https://db.example/only.git"), _local_row(str(local_dir))],
|
||||
"db",
|
||||
),
|
||||
)
|
||||
settings = _settings(
|
||||
git_sources="https://env.example/ignored.git",
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only", local_dir]
|
||||
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||
|
||||
|
||||
def test_resolve_sources_missing_local_dir_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 38: a local row whose directory is gone at run time →
|
||||
``GitSyncError`` naming the path, before any import (the same
|
||||
pre-import fail-loud as a failing git clone)."""
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", _fake_clone_factory()[1])
|
||||
missing = tmp_path / "Gone"
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([_local_row(str(missing))], "db"),
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
with pytest.raises(GitSyncError, match=f"local source missing: {re.escape(str(missing))}"):
|
||||
import_docs._resolve_sources(None, settings)
|
||||
|
||||
|
||||
def test_main_git_failure_aborts_before_import(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
@@ -236,7 +310,9 @@ def test_main_git_failure_aborts_before_import(
|
||||
)
|
||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
import_docs, "effective_git_sources", lambda db: (["https://host/a/bad.git"], "env")
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([_git_row("https://host/a/bad.git")], "env"),
|
||||
)
|
||||
|
||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||
@@ -253,7 +329,34 @@ def test_main_git_failure_aborts_before_import(
|
||||
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "import_docs: git sync failed" in err
|
||||
assert "import_docs: source sync failed" in err
|
||||
assert "bad.git" in err # the failing repo is named
|
||||
assert fake_import.calls == [] # no partial import
|
||||
assert not (tmp_path / "bor").exists()
|
||||
|
||||
|
||||
def test_main_missing_local_dir_aborts_before_import(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Phase 38: a DB local row whose directory is missing → exit code
|
||||
1, ``local source missing: <path>`` on stderr, zero import attempts
|
||||
(no ``--source`` given, so the DB row is what should have been
|
||||
imported)."""
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||
missing = tmp_path / "Gone"
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([_local_row(str(missing))], "db"),
|
||||
)
|
||||
fake_import = FakeImportSources()
|
||||
monkeypatch.setattr(import_docs, "import_sources", fake_import)
|
||||
|
||||
rc = import_docs.main([])
|
||||
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "import_docs: source sync failed" in err
|
||||
assert f"local source missing: {missing}" in err # the path is named
|
||||
assert fake_import.calls == [] # no partial import
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Integration: migration 0007 (git_sources.kind + path) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the style of
|
||||
``test_migration_0004.py`` / ``test_migration_0006.py``
|
||||
(information_schema assertions on the state the migration must leave).
|
||||
The tests target revision ``0007`` explicitly so later migrations
|
||||
cannot break them:
|
||||
|
||||
* upgrade 0006 → 0007 → ``git_sources`` gains ``kind TEXT NOT NULL``
|
||||
(server default ``'git'``, check constraint ``ck_git_sources_kind``:
|
||||
``kind IN ('git', 'local')``) and ``path TEXT`` (nullable) with the
|
||||
unique index ``uq_git_sources_path``; a row inserted before the
|
||||
upgrade (the pre-0007 insert shape) keeps ``kind='git'`` /
|
||||
``path=NULL`` after it;
|
||||
* the check constraint rejects any kind other than ``git``/``local``;
|
||||
* the unique index rejects duplicate local paths but tolerates NULL
|
||||
paths (git rows);
|
||||
* downgrade to 0006 → both columns, the constraint, and the index are
|
||||
gone;
|
||||
* upgrade back to 0007 → they are back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
URL_BASE = "https://git.example.com/mig0007"
|
||||
PATH_BASE = "/tmp/brain-of-reese-mig0007"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one git_sources column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = 'git_sources' AND column_name = :c"
|
||||
),
|
||||
{"c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _check_constraint_def(db: Session) -> str | None:
|
||||
"""Definition of ``ck_git_sources_kind``, or None if it does not exist.
|
||||
|
||||
Only call while ``git_sources`` exists (the ``::regclass`` cast errors
|
||||
otherwise).
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT pg_get_constraintdef(oid) FROM pg_constraint"
|
||||
" WHERE conname = 'ck_git_sources_kind'"
|
||||
" AND conrelid = 'git_sources'::regclass"
|
||||
)
|
||||
).fetchone()
|
||||
return row[0] if row is not None else None
|
||||
|
||||
|
||||
def _unique_path_index(db: Session) -> int:
|
||||
"""1 iff ``uq_git_sources_path`` exists as a UNIQUE index."""
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM pg_indexes"
|
||||
" WHERE tablename = 'git_sources' AND indexname = 'uq_git_sources_path'"
|
||||
" AND indexdef ILIKE 'CREATE UNIQUE%'"
|
||||
)
|
||||
).scalar()
|
||||
assert count is not None, "pg_indexes count must be an int"
|
||||
return int(count)
|
||||
|
||||
|
||||
def _insert(db: Session, url: str, kind: str | None = None, path: str | None = None) -> None:
|
||||
"""Insert one git_sources row; kind/path omitted → pre-0007 shape."""
|
||||
if kind is None and path is None:
|
||||
db.execute(
|
||||
text("INSERT INTO git_sources (id, url) VALUES (gen_random_uuid(), :u)"),
|
||||
{"u": url},
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO git_sources (id, url, kind, path)"
|
||||
" VALUES (gen_random_uuid(), :u, :k, :p)"
|
||||
),
|
||||
{"u": url, "k": kind, "p": path},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _delete_by_url(db: Session, url: str) -> None:
|
||||
db.execute(text("DELETE FROM git_sources WHERE url = :u"), {"u": url})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0007_adds_kind_and_path(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0006 → 0007: both columns exist with the locked types,
|
||||
nullability, and defaults, plus the CHECK constraint and the unique
|
||||
path index."""
|
||||
command.downgrade(alembic, "0006") # start from the pre-0007 state
|
||||
assert _version(db) == "0006"
|
||||
|
||||
command.upgrade(alembic, "0007")
|
||||
assert _version(db) == "0007", "alembic_version must be at 0007"
|
||||
|
||||
kind = _column(db, "kind")
|
||||
assert kind is not None, "git_sources.kind is missing"
|
||||
assert kind[0] == "text", "git_sources.kind must be TEXT"
|
||||
assert kind[1] == "NO", "git_sources.kind must be NOT NULL"
|
||||
assert kind[2] is not None and "'git'" in kind[2], (
|
||||
"git_sources.kind must have server default 'git'"
|
||||
)
|
||||
|
||||
path = _column(db, "path")
|
||||
assert path is not None, "git_sources.path is missing"
|
||||
assert path[0] == "text", "git_sources.path must be TEXT"
|
||||
assert path[1] == "YES", "git_sources.path must be NULLABLE"
|
||||
|
||||
constraint = _check_constraint_def(db)
|
||||
assert constraint is not None, "ck_git_sources_kind is missing"
|
||||
assert "git" in constraint and "local" in constraint, (
|
||||
f"ck_git_sources_kind must restrict kind to git|local, got: {constraint}"
|
||||
)
|
||||
|
||||
assert _unique_path_index(db) == 1, "uq_git_sources_path unique index is missing"
|
||||
|
||||
|
||||
def test_pre_0007_row_reads_as_git(db: Session, alembic: Config) -> None:
|
||||
"""A row inserted before the upgrade (url only — the pre-0007 insert
|
||||
shape) reads as ``kind='git'``, ``path=NULL`` after it."""
|
||||
command.downgrade(alembic, "0006")
|
||||
url = f"{URL_BASE}/pre-existing.git"
|
||||
_insert(db, url) # no kind/path columns exist at 0006
|
||||
try:
|
||||
command.upgrade(alembic, "0007")
|
||||
kind, path = db.execute(
|
||||
text("SELECT kind, path FROM git_sources WHERE url = :u"), {"u": url}
|
||||
).one()
|
||||
assert kind == "git", "a pre-0007 row must read as kind='git'"
|
||||
assert path is None, "a pre-0007 row must keep path=NULL"
|
||||
finally:
|
||||
_delete_by_url(db, url)
|
||||
|
||||
|
||||
def test_kind_defaults_to_git_for_new_inserts(db: Session, alembic: Config) -> None:
|
||||
"""An insert that omits kind (the API's pre-phase-38 shape) lands as
|
||||
``kind='git'`` via the server default."""
|
||||
command.upgrade(alembic, "head")
|
||||
url = f"{URL_BASE}/default-kind.git"
|
||||
_insert(db, url)
|
||||
try:
|
||||
kind, path = db.execute(
|
||||
text("SELECT kind, path FROM git_sources WHERE url = :u"), {"u": url}
|
||||
).one()
|
||||
assert kind == "git", "git_sources.kind must default to 'git'"
|
||||
assert path is None, "git_sources.path must default to NULL"
|
||||
finally:
|
||||
_delete_by_url(db, url)
|
||||
|
||||
|
||||
def test_check_constraint_rejects_unknown_kind(db: Session, alembic: Config) -> None:
|
||||
"""``ck_git_sources_kind`` is what later API validation relies on:
|
||||
any kind other than git|local raises IntegrityError."""
|
||||
command.upgrade(alembic, "head")
|
||||
with pytest.raises(IntegrityError):
|
||||
_insert(db, f"{URL_BASE}/bogus-kind.git", kind="bogus")
|
||||
db.rollback() # the IntegrityError aborts the open transaction
|
||||
|
||||
|
||||
def test_duplicate_local_path_rejected(db: Session, alembic: Config) -> None:
|
||||
"""The unique path index is what the API's 409 relies on: two local
|
||||
rows with the same path are rejected (NULL paths stay distinct —
|
||||
git rows are unaffected)."""
|
||||
command.upgrade(alembic, "head")
|
||||
url_a = f"{URL_BASE}/dup-path-a.git"
|
||||
url_b = f"{URL_BASE}/dup-path-b.git"
|
||||
url_c = f"{URL_BASE}/dup-path-c.git"
|
||||
path = f"{PATH_BASE}/shared"
|
||||
try:
|
||||
_insert(db, url_a, kind="local", path=path)
|
||||
with pytest.raises(IntegrityError):
|
||||
_insert(db, url_b, kind="local", path=path)
|
||||
db.rollback()
|
||||
# NULL paths are distinct under the unique index (git rows).
|
||||
_insert(db, url_b)
|
||||
_insert(db, url_c)
|
||||
finally:
|
||||
db.rollback()
|
||||
_delete_by_url(db, url_a)
|
||||
_delete_by_url(db, url_b)
|
||||
_delete_by_url(db, url_c)
|
||||
|
||||
|
||||
def test_downgrade_to_0006_drops_columns(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0006: both columns, the CHECK constraint, and the
|
||||
unique index are dropped (A13 — reversible)."""
|
||||
command.downgrade(alembic, "0006")
|
||||
assert _version(db) == "0006"
|
||||
|
||||
assert _column(db, "kind") is None, "git_sources.kind must be dropped"
|
||||
assert _column(db, "path") is None, "git_sources.path must be dropped"
|
||||
assert _check_constraint_def(db) is None, "ck_git_sources_kind must be dropped"
|
||||
assert _unique_path_index(db) == 0, "uq_git_sources_path must be dropped"
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_columns(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0006, then upgrade back to 0007: columns, default,
|
||||
constraint, and index are back."""
|
||||
command.downgrade(alembic, "0006")
|
||||
command.upgrade(alembic, "0007")
|
||||
assert _version(db) == "0007", "round-trip upgrade must land at 0007"
|
||||
|
||||
kind = _column(db, "kind")
|
||||
assert kind is not None, "git_sources.kind must be back"
|
||||
assert kind[1] == "NO" and kind[2] is not None and "'git'" in kind[2], (
|
||||
"git_sources.kind must keep its NOT NULL 'git' default after the round-trip"
|
||||
)
|
||||
|
||||
path = _column(db, "path")
|
||||
assert path is not None and path[1] == "YES", "git_sources.path must be back"
|
||||
|
||||
constraint = _check_constraint_def(db)
|
||||
assert constraint is not None, "ck_git_sources_kind must be back"
|
||||
|
||||
assert _unique_path_index(db) == 1, "uq_git_sources_path must be back"
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Integration: the admin sources-sync API (phase 32, task 01; phase 35,
|
||||
task 03 re-points the URL resolution at the shared resolver).
|
||||
task 03 re-points the URL resolution at the shared resolver; phase 38,
|
||||
task 03 adds the local kind).
|
||||
|
||||
Covers the in-process sync runner end to end over HTTP: anonymous 403s
|
||||
on both endpoints; admin idle → 202 → ``success`` with the full
|
||||
ImportSummary detail; 409 on a double trigger while a run is in flight;
|
||||
``GitSyncError`` → ``failed`` with the failing repo named and **zero**
|
||||
import attempts; empty on *both* origins (``git_sources`` table +
|
||||
``BOR_GIT_SOURCES``) → ``failed`` loudly; an embedding failure →
|
||||
``failed`` with any credentials masked; the import always runs with
|
||||
``prune=True``; and the phase-31 overview trigger is change-gated (no
|
||||
``lite`` call on an unchanged KB).
|
||||
import attempts; empty on *both* origins (no git rows, no local rows,
|
||||
no env URLs) → ``failed`` loudly (``no sources configured
|
||||
(git or local)``); an embedding failure → ``failed`` with any
|
||||
credentials masked; the import always runs with ``prune=True``; and the
|
||||
phase-31 overview trigger is change-gated (no ``lite`` call on an
|
||||
unchanged KB).
|
||||
|
||||
Phase 35: the runner resolves the repos through
|
||||
:func:`app.rag.git_sources.effective_git_sources` — the **real**
|
||||
resolver against the **real** ``git_sources`` table (truncated around
|
||||
every test), so DB-over-env and the env fallback go through the actual
|
||||
Phase 38 (local kind): local-only, git-only, and mixed syncs over a
|
||||
**host temp local dir** (the app server runs on the same host) — the
|
||||
mixed run goes through the **real** ``import_sources`` (deterministic
|
||||
in-process ``FakeEmbedder``, no network), so the local file verifiably
|
||||
lands in the KB via ``GET /api/docs`` and union pruning holds (a file
|
||||
deleted out of the local dir is pruned on the next sync while the git
|
||||
doc survives); a local directory missing at sync time → ``failed`` with
|
||||
``local source missing: <path>`` and **zero** import attempts.
|
||||
|
||||
Phase 35: the runner resolves the sources through
|
||||
:func:`app.rag.git_sources.effective_sources` — the **real** resolver
|
||||
against the **real** ``git_sources`` table (truncated around every
|
||||
test), so DB-over-env and the env fallback go through the actual
|
||||
indirection; the env list is driven by a fresh ``Settings`` on the
|
||||
resolver's module (the dev ``.env`` never leaks in).
|
||||
|
||||
@@ -34,7 +45,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -51,6 +62,7 @@ from app.rag.importer import ImportSummary
|
||||
from app.rag.llm import EmbeddingError, LLMClient
|
||||
from scripts.git_sync import GitSyncError
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -110,6 +122,30 @@ def _seed(db: Session, url: str) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_local(db: Session, path: Path) -> None:
|
||||
"""A ``kind=local`` row as the phase-38 API stores it: the expanded
|
||||
absolute path in both ``path`` and the NOT-NULL ``url`` column."""
|
||||
db.add(GitSource(url=str(path), kind="local", path=str(path)))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clean_documents(db: Session) -> Iterator[None]:
|
||||
"""The real-import tests write ``documents``/``chunks`` (the canonical
|
||||
KB state) — global, truncated around every such test."""
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _real_llm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The pipeline's ``LLMClient`` becomes the deterministic in-process
|
||||
``FakeEmbedder`` (real import, no network)."""
|
||||
monkeypatch.setattr(sync_api, "LLMClient", lambda: FakeEmbedder())
|
||||
|
||||
|
||||
def _login(client: TestClient) -> None:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
@@ -372,11 +408,12 @@ def test_git_failure_marks_failed_and_skips_import(
|
||||
_poll(sync_client, "failed")
|
||||
|
||||
|
||||
def test_no_git_sources_configured_fails_loudly(
|
||||
def test_no_sources_configured_fails_loudly(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Both origins empty (the truncate fixture + a blank env) → the
|
||||
fail-loud error names *both* (phase 35)."""
|
||||
"""Both origins empty — no git rows, no local rows, no env URLs
|
||||
(the truncate fixture + a blank env) → the fail-loud error
|
||||
(phase 38: git-only message retired)."""
|
||||
# Whitespace-only is just as unconfigured as empty.
|
||||
_stub_env(monkeypatch, " , ")
|
||||
monkeypatch.setattr(
|
||||
@@ -393,9 +430,7 @@ def test_no_git_sources_configured_fails_loudly(
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert body["error"] == (
|
||||
"no git sources configured (git_sources table empty and BOR_GIT_SOURCES unset)"
|
||||
)
|
||||
assert body["error"] == "no sources configured (git or local)"
|
||||
assert clone_calls == [] # git is never touched
|
||||
assert fake_import.sources == []
|
||||
|
||||
@@ -464,6 +499,141 @@ def test_env_fallback_when_table_empty(
|
||||
assert any("sync: started repos=1 origin=env" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
# --- phase 38: the local kind (real import, host temp dirs) ---------------
|
||||
|
||||
|
||||
def test_local_only_sync_imports_dir(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
clean_documents: None,
|
||||
) -> None:
|
||||
"""Local-only config: the host temp dir (one fixture ``.md``) is
|
||||
walked directly — no clone at all — and the file lands in the KB
|
||||
(``GET /api/docs`` as admin)."""
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "notes.md").write_text("# Local Notes\nthe local fixture\n", encoding="utf-8")
|
||||
_seed_local(db, local_dir)
|
||||
_stub_env(monkeypatch) # env must not matter once the table has a row
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
clone_calls, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_real_llm(monkeypatch) # real import_sources, deterministic embeddings
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
assert clone_calls == [] # nothing to clone — local is walked directly
|
||||
assert body["detail"]["added"] == 1
|
||||
assert body["detail"]["errors"] == 0
|
||||
# The local file is in the KB, sourced by the directory's basename.
|
||||
docs = sync_client.get("/api/docs").json()["documents"]
|
||||
assert [(d["source"], d["path"]) for d in docs] == [("LocalDocs", "notes.md")]
|
||||
|
||||
|
||||
def test_mixed_git_local_sync_imports_both_and_prunes_union(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
clean_documents: None,
|
||||
) -> None:
|
||||
"""Mixed config: the git row is cloned, the local dir walked, and
|
||||
both are imported in one run over the single combined list. The
|
||||
started log carries the kind counts; a file deleted out of the
|
||||
local dir is pruned on the next sync (union prune) while the git
|
||||
doc survives."""
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
_seed(db, repo_url)
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "a.md").write_text("# A\nfirst local file\n", encoding="utf-8")
|
||||
(local_dir / "b.md").write_text("# B\nsecond local file\n", encoding="utf-8")
|
||||
_seed_local(db, local_dir)
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
clone_calls, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_real_llm(monkeypatch)
|
||||
|
||||
_login(sync_client)
|
||||
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
# Git cloned into BOR_SOURCES_DIR, local dir walked in row order.
|
||||
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")]
|
||||
assert body["detail"]["added"] == 3
|
||||
assert any(
|
||||
"sync: started repos=2 origin=db git=1 local=1" in r.getMessage() for r in caplog.records
|
||||
)
|
||||
docs = {(d["source"], d["path"]) for d in sync_client.get("/api/docs").json()["documents"]}
|
||||
assert docs == {("repo", "notes.md"), ("LocalDocs", "a.md"), ("LocalDocs", "b.md")}
|
||||
|
||||
# Union prune: delete one local file → the next sync prunes exactly
|
||||
# it; the git doc (and the surviving local file) stay.
|
||||
(local_dir / "b.md").unlink()
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
assert body["detail"]["pruned"] == 1
|
||||
assert body["detail"]["added"] == 0
|
||||
docs = {(d["source"], d["path"]) for d in sync_client.get("/api/docs").json()["documents"]}
|
||||
assert docs == {("repo", "notes.md"), ("LocalDocs", "a.md")}
|
||||
|
||||
|
||||
def test_missing_local_dir_fails_loudly_and_imports_nothing(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""A local row whose directory is gone at sync time (moved/deleted
|
||||
since add-time) → ``failed`` naming the path, **zero** import
|
||||
attempts — the git row before it in row order was still cloned
|
||||
(per-row walk; a clone is not an import)."""
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
missing = tmp_path / "Gone"
|
||||
db.add(
|
||||
GitSource(url=repo_url, kind="git", added_at=datetime(2026, 1, 1, tzinfo=UTC))
|
||||
)
|
||||
db.add(
|
||||
GitSource(url=str(missing), kind="local", path=str(missing),
|
||||
added_at=datetime(2026, 1, 2, tzinfo=UTC))
|
||||
)
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
clone_calls, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
fake_import = FakeImportSources(ImportSummary())
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
fake_overview = FakeOverview(ok=True)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert f"local source missing: {missing}" in body["error"] # the path is named
|
||||
assert body["detail"] == {}
|
||||
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")] # git row walked first
|
||||
assert fake_import.sources == [] # no partial import
|
||||
assert fake_overview.llms == []
|
||||
|
||||
|
||||
def test_import_error_is_reported_with_credentials_masked(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user