Files
brain-of-reese/tests/integration/test_git_sources_upload.py
ducoterra 3095c4c577 phase: 90_upload_no_scan
All verification is complete and green. Final report:

**Phase 90 (upload_no_scan) — final verification pass: all criteria verified, no fixes needed**

- Verified (not re-implemented): all 3 tasks already complete; working tree carries the phase's uncommitted changes
- Upload pipeline: `_run_upload` stops after row upsert; success = `{"message": "uploaded"}`, null/0/0 progress; `UploadOut` removed from `app/schemas.py`; gates/unpack/swap/failed states intact
- Copy: button "Upload", bare "Processing…", result line "Uploaded \<name\> — press Sync sources to import it."; hint + caption re-pointed at Sync; no "Upload &" remnants in `frontend/`
- Tests: `pytest tests/unit/test_git_sources.py tests/integration/test_git_sources_upload.py tests/unit/test_frontend_sync_upload.py` → 56 passed; E2E phase suite `tests/e2e/test_upload_no_scan.py` → 3 passed (zero docs after upload; ignore edit honored by Sync — 2 added, `notes/skipme.md` excluded; re-upload in-place, still zero docs); affected suites `test_archive_upload_sources.py` + `test_sync_upload_progress.py` + `test_source_removal_cleanup.py` → 15 passed
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **1808 passed, 99% coverage** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors

**Completion criteria:**
1. Upload settles "ready for sync", zero docs, row + ignore editor — ✅ (E2E test 1)
2. Button "Upload", no "Upload &" copy — ✅ (`rg` empty in frontend/, app/, tests/)
3. RAG-page "Sync sources" imports upload honoring edited ignores — ✅ (isolated E2E test 2)
4. Suite green, >90% coverage, lint clean — ✅ (99%, ruff+pyright clean)
5. Atomic commit — ✅ left to harness per executor rules (no git add/commit run; tree staged-ready)

**Deviations:** `regenerate_overview` still appears once in `git_sources.py` — inside `delete_git_source` (phase-69 locked total-removal behavior, not the upload leg; upload leg is scan-free). `.agents/PLAN.md` does not exist in this repo; locked decisions A1–A4 were enforced from the phase files.

**Next pending phase:** `91_admin_theme_tab` (in `todo/`).
2026-09-09 14:08:18 -04:00

1467 lines
59 KiB
Python

"""Integration: the admin archive-upload API (phase 49, task 02;
backgrounded in phase 64, task 03; scan deferred in phase 90, task 01).
Real Postgres (``podman compose up -d db``); the upload dir is pointed
at a fresh tmp dir per test by monkeypatching the router's
``get_settings`` (the ``test_git_sources_api.py`` pattern — the dev
``.env`` never leaks in).
Phase 90 (task 01) contract under test: ``POST`` answers **202** the
moment the archive is safely on disk, and the rest — unpack → swap →
row upsert, and **nothing else** (no model check, no import, no
overview refresh — the scan is the RAG page's "Sync sources" button's
job, phase 90 A1) — runs in a **background task** behind
``GET /api/git-sources/upload/status`` (the phase-32 ``SyncStatus``
shape). The success ``detail`` is the no-count ``{"message":
"uploaded"}`` payload and ``current_file`` / ``files_done`` /
``files_total`` stay null/0/0 for the whole run (phase 90 A2 — the key
set is unchanged; the live file label belongs to the sync). Every
post-receive scenario observes the **status endpoint, polled until
terminal** — never the HTTP response; post-202 failures are status
states (``failed`` + sanitized error, phase 64 A5), never HTTP errors.
The upload touches NO KB state: zero documents, zero chunks, no
overview row — asserted on the happy paths (the sync suites already
prove a fresh upload row is imported on the next sync, and the phase-90
E2E proves the full loop with an edited ignore list).
The inline gates are unchanged:
* anonymous → 403 ``{"detail": "admin only"}`` (the router's
``require_admin`` covers the new status route too);
* name/format gate → 422: a non-archive extension names the accepted
formats; a ``..`` / separator / empty-stem name (including a bare
``tar.gz``) is rejected with the task-01 message — and the upload
dir is never created for a rejected name (control characters never
reach the app: the multipart transport percent-encodes them — the
task-01 branch for them is covered in ``test_archive_upload.py``);
* one upload at a time → 409 ``an upload is already in progress`` —
the gate is the module-level **flag** (checked and set with no await
in between, BEFORE the receive — the handler now awaits before the
background task exists), held through the whole background run;
* streaming cap → 413 naming the ``upload_max_mb`` cap; the temp
``.upload`` file is removed and the flag is released (no stray
``.`` files in the upload dir; the next upload is not 409).
Background branches (phase 64 A5): unpack failure (zip-slip, tar
symlink escape, corrupt archive), zero-entry archive, swap failure,
the concurrent-insert ``IntegrityError`` backstop, and a cancelled run
(the ``finally`` cleans both temps + releases the flag) — each lands
in the status as ``failed`` (with the sanitized error) or ``success``
(with the no-count payload in ``detail``); a FAILED upload **never**
touches the previous folder, row, or KB of an earlier good upload (the
no-partial-state locked decision, asserted explicitly).
The mid-run observations (409 window, in-flight status, TRUNCATE
regression) park the run in a SYNCHRONOUS gate (unpack or swap — the
run has no await: phase 90 removed the scan). A synchronous park also
holds the 202's response delivery on the first client (the TestClient
portal wakes the main thread with a loop callback that cannot run
while the loop is blocked), so those tests DRIVE ``_run_upload``
directly on a worker loop and observe from a SECOND client (its own
loop), the module-level status, or the test's own thread. The
flip side of the fast, synchronous-only run is pinned at the handler
level instead: by the time the 202 is observed, the background run
(unpack → swap → upsert, queued on the loop BEFORE the response is
delivered) has already landed its terminal state — the status is the
source of truth, and the 202 body never carries the outcome.
``git_sources`` / ``documents`` / ``chunks`` / ``kb_overview`` are
global state: truncated around every test.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import logging
import os
import re
import tarfile
import threading
import time
import zipfile
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Literal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api import git_sources as git_sources_api
from app.config import Settings
from app.db import SessionLocal
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag.archive_upload import ArchiveUploadError, swap_in, unpack_archive
from tests.conftest import ADMIN_PASSWORD
@pytest.fixture(autouse=True)
def clean_git_sources(db: Session) -> Iterator[None]:
"""The stored list is global state: reset around every test."""
db.execute(text("TRUNCATE git_sources"))
db.commit()
yield
db.execute(text("TRUNCATE git_sources"))
db.commit()
@pytest.fixture(autouse=True)
def clean_documents(db: Session) -> Iterator[None]:
"""Phase 90: the upload touches no KB state — these tables must be
empty BEFORE every test so the ``_kb_untouched`` assertions (zero
documents / chunks / overview rows) are meaningful, and they stay
empty after (global state, truncated around every test)."""
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
@pytest.fixture(autouse=True)
def fresh_upload_status() -> Iterator[None]:
"""The module-level upload status + flag are process-global: reset
them before AND after every test (the ``fresh_sync_status`` pattern
from ``tests/unit/test_sync_button.py``) — a previous test's
terminal state must never leak into the next one's idle-shape
assertions or its 409 expectations."""
git_sources_api._upload_status = git_sources_api.UploadStatus()
git_sources_api._upload_in_progress = False
yield
git_sources_api._upload_status = git_sources_api.UploadStatus()
git_sources_api._upload_in_progress = False
@pytest.fixture()
def upload_client() -> Iterator[TestClient]:
"""Admin-signed client (the context-manager form keeps one event
loop for the whole test — the background task the 202 creates runs
on it, so the client must stay open until the run is observed in a
terminal state)."""
with TestClient(fastapi_app) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
yield client
def _point_at(monkeypatch: pytest.MonkeyPatch, upload_dir: Path, upload_max_mb: int = 512) -> None:
"""Fresh settings on the router's module: the tmp upload dir and
(optionally) a shrunk cap — the dev ``.env`` never leaks in."""
monkeypatch.setattr(
git_sources_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
upload_dir=str(upload_dir),
upload_max_mb=upload_max_mb,
),
)
#: tarfile write modes used by the tests (uncompressed + gzip).
_TAR_WRITE_MODES = Literal["w", "w:gz"]
def _tarball(path: Path, files: dict[str, str], compress: _TAR_WRITE_MODES = "w:gz") -> Path:
with tarfile.open(path, compress) as tf:
for rel, content in files.items():
data = content.encode("utf-8")
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mtime = 0
tf.addfile(info, io.BytesIO(data))
return path
def _zip(path: Path, files: dict[str, str]) -> Path:
with zipfile.ZipFile(path, "w") as zf:
for rel, content in files.items():
zf.writestr(rel, content)
return path
def _targz_bytes(files: dict[str, str]) -> bytes:
"""A tarball in memory (no temp file on disk)."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for rel, content in files.items():
data = content.encode("utf-8")
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mtime = 0
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _post(client: TestClient, filename: str, payload: bytes):
"""One upload request (multipart, the page's exact shape)."""
return client.post(
"/api/git-sources/upload",
files={"file": (filename, payload, "application/octet-stream")},
)
def _wait_status(client: TestClient, timeout: float = 30.0) -> dict[str, Any]:
"""Poll ``GET /api/git-sources/upload/status`` until the run is
terminal (``success`` / ``failed``) and return the terminal body.
Phase 64: the 202 answers BEFORE the background run completes —
every post-receive assertion observes the status endpoint, not the
HTTP response. Phase 90: the run is short (unpack → swap → row
upsert, no scan), so a terminal state is usually already there by
the first poll — the poll loop is the shape contract, not a
timing assumption."""
deadline = time.monotonic() + timeout
body: dict[str, Any] = {}
while True:
r = client.get("/api/git-sources/upload/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
if time.monotonic() >= deadline:
raise AssertionError(f"upload never reached a terminal state: {body}")
time.sleep(0.05)
def _row(db: Session, path: str) -> GitSource | None:
db.expire_all()
return db.scalar(select(GitSource).where(GitSource.path == path))
def _docs(client: TestClient) -> list[tuple[str, str]]:
body = client.get("/api/docs").json()
return [(d["source"], d["path"]) for d in body["documents"]]
def _count_rows(db: Session) -> int:
return db.execute(text("SELECT count(*) FROM git_sources")).scalar_one()
def _kb_untouched(db: Session, client: TestClient) -> None:
"""Phase 90: the upload never touches the KB — zero documents,
zero chunks, no overview row (the scan that populated them moved
to the RAG page's "Sync sources" button)."""
assert _docs(client) == []
assert db.execute(text("SELECT count(*) FROM chunks")).scalar_one() == 0
assert db.execute(text("SELECT count(*) FROM kb_overview")).scalar_one() == 0
def _good_upload(
client: TestClient, name: str = "safe", files: dict[str, str] | None = None
) -> None:
"""A successful upload of a two-sentinel tarball (202 → status
``success`` — no KB state, phase 90) — the baseline state the
no-partial-state tests protect."""
payload_files = files or {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
r = _post(client, f"{name}.tar.gz", _targz_bytes(payload_files))
assert r.status_code == 202, r.text
assert _wait_status(client)["state"] == "success"
def _assert_previous_intact(
client: TestClient, db: Session, upload_dir: Path, name: str, files: dict[str, str]
) -> None:
"""The no-partial-state locked decision, asserted explicitly: after a
FAILED upload the previous folder's content, the row, and the KB
are all exactly as the good upload left them (phase 90: an upload
touches no KB state — zero documents) — and no temp file
survived."""
folder = upload_dir / name
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == files
assert _row(db, str(folder)) is not None # the row is still there
assert _count_rows(db) == 1 # …and no second row appeared
assert _docs(client) == [] # the KB was never touched (phase 90)
assert [p.name for p in upload_dir.iterdir()] == [name] # no stray temp
class _GatedUnpack:
"""Parks inside ``unpack_archive`` — a SYNCHRONOUS seam (the app
loop blocks while parked — the observer must come from a second
client or the test's own thread, never the first client's loop) —
then runs the REAL unpack so the run completes for real."""
def __init__(self, started: threading.Event, release: threading.Event) -> None:
self.started = started
self.release = release
def __call__(self, archive: Path, target: Path, max_bytes: int) -> None:
self.started.set()
self.release.wait(30.0)
unpack_archive(archive, target, max_bytes)
class _GatedSwap:
"""Parks inside ``swap_in`` (the same synchronous-seam contract as
:class:`_GatedUnpack`) — then runs the REAL swap so the run
completes for real."""
def __init__(self, started: threading.Event, release: threading.Event) -> None:
self.started = started
self.release = release
def __call__(self, new_dir: Path, final_dir: Path) -> None:
self.started.set()
self.release.wait(30.0)
swap_in(new_dir, final_dir)
@contextlib.contextmanager
def _second_client() -> Iterator[TestClient]:
"""A fresh admin-signed client on its OWN event loop — the mid-run
observer for tests whose first client's loop is blocked in a
synchronous park (phase 90: the run has no await, so its gates are
synchronous)."""
with TestClient(fastapi_app) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
yield client
# --- anonymous -------------------------------------------------------------
def test_anonymous_upload_and_status_get_403(client: TestClient, db: Session) -> None:
r = _post(client, "homelab.tar.gz", b"not an archive at all")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The status route sits behind the same router-level dependency.
r = client.get("/api/git-sources/upload/status")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert _count_rows(db) == 0
def test_idle_upload_status_pins_full_shape(upload_client: TestClient) -> None:
"""Idle: the full response dict is pinned — identical key set to
``GET /api/sync/status`` (phase 32), progress keys null/0/0."""
assert upload_client.get("/api/git-sources/upload/status").json() == {
"state": "idle",
"started_at": None,
"finished_at": None,
"detail": {},
"error": None,
"current_file": None,
"files_done": 0,
"files_total": 0,
}
# --- name / format gate -----------------------------------------------------
def test_non_archive_extension_gets_422_naming_formats(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(upload_client, "notes.txt", b"hello world")
assert r.status_code == 422
assert r.json()["detail"] == "only .tar, .tar.gz, .tgz or .zip archives are accepted"
assert _count_rows(db) == 0
# The name gate runs before the dir is created — nothing on disk.
assert not uploads.exists()
@pytest.mark.parametrize(
"filename",
[
"../evil.zip", # ``..`` + separator
"sub/dir.tar.gz", # separator
"tar.gz", # bare suffix → empty stem
".tar.gz", # empty stem
# (control characters in the filename are percent-encoded by the
# multipart transport before the app ever sees them — the
# ``archive_source_name`` branch for them is covered in
# tests/unit/test_archive_upload.py)
],
)
def test_unsafe_name_gets_422(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
filename: str,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(upload_client, filename, b"content")
assert r.status_code == 422, f"{filename!r} must be rejected: {r.text}"
assert r.json()["detail"] != "only .tar, .tar.gz, .tgz or .zip archives are accepted"
assert _count_rows(db) == 0
assert not uploads.exists()
# --- one at a time (409) ----------------------------------------------------
def test_second_upload_while_one_is_in_flight_returns_409(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""While a background run is in flight, the second upload is 409 —
the module-level flag, held from the first receive, is the gate —
and after the first run lands in a terminal state, uploads are
accepted again (a full real run this time).
The park is a SYNCHRONOUS seam (phase 90: the run is unpack → swap
→ row upsert, no await), so the in-flight run is driven directly
on a worker loop (the house background-task pattern from
``test_sync_button.py``); the second upload comes from a real HTTP
client (its own loop) to observe the 409 while the first run is
held."""
uploads = tmp_path / "uploads"
uploads.mkdir()
_point_at(monkeypatch, uploads)
archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
temp_upload = uploads / ".homelab.0.upload"
temp_unpack = uploads / ".homelab.0.unpack"
temp_upload.write_bytes(archive.read_bytes())
started = threading.Event()
release = threading.Event()
monkeypatch.setattr(git_sources_api, "unpack_archive", _GatedUnpack(started, release))
# The handler holds the flag when it creates the task — simulate.
git_sources_api._upload_in_progress = True
errors: list[BaseException] = []
def _run() -> None:
try:
asyncio.run(
git_sources_api._run_upload(
"homelab", "homelab.tar.gz", len(archive.read_bytes()),
uploads, temp_upload, temp_unpack,
)
)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
assert started.wait(15.0), "background run did not reach its unpack"
# The run is in flight: the flag (not the task's done-ness) is
# held — the second upload is 409.
with _second_client() as second:
r = _post(second, "homelab.tar.gz", archive.read_bytes())
assert r.status_code == 409
assert r.json() == {"detail": "an upload is already in progress"}
release.set()
thread.join(20)
finally:
release.set()
assert not thread.is_alive()
assert errors == [], f"the run raised: {errors!r}"
terminal = git_sources_api._upload_status
assert terminal.state == "success", terminal
assert terminal.detail == {"message": "uploaded"}
# The folder is in place, no temp survived, the flag is released.
assert [p.name for p in uploads.iterdir()] == ["homelab"]
assert git_sources_api._upload_in_progress is False
# The flag was released: the next upload goes through for real
# (a full HTTP run — 202 + the terminal status).
with TestClient(fastapi_app) as third:
assert third.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = _post(third, "homelab.tar.gz", archive.read_bytes())
assert r.status_code == 202, r.text
assert _wait_status(third)["state"] == "success"
def test_upload_refused_while_flag_held(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""The smallest test seam: the module-level flag itself. Held →
409 and nothing happens (no dir, no row) — even though the status
is still ``idle`` (no run exists at all): the flag, not a
task/state check, is the gate. That is what the receive window
needs — the handler awaits the (1 MiB-chunk) streaming receive
BEFORE the background task exists, so only the flag can stop a
concurrent POST from starting a second run."""
_point_at(monkeypatch, tmp_path / "uploads")
monkeypatch.setattr(git_sources_api, "_upload_in_progress", True)
r = _post(upload_client, "a.tar.gz", b"x")
assert r.status_code == 409
assert r.json() == {"detail": "an upload is already in progress"}
# The flag — not the status state — is the gate:
assert upload_client.get("/api/git-sources/upload/status").json()["state"] == "idle"
assert _count_rows(db) == 0
assert not (tmp_path / "uploads").exists()
# --- the 202 lands with the archive on disk (phase 64 A2) --------------------
def test_202_is_followed_immediately_by_the_terminal_status(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""The 202 is the "archive safely on disk" moment (phase 64 A2) and
never carries the outcome (the 202 body is the ``UploadAccepted``
name only); the outcome lives in the status, which the UI polls.
Phase 90: the run is a fast, synchronous-only pipeline (unpack →
swap → upsert, no await) queued on the event loop BEFORE the 202's
response is delivered (the TestClient portal wakes the main thread
with a loop callback that runs AFTER the run's queued step), so
the flip side of the deferred scan is deterministic at the handler
level: by the time the 202 is observed, the run has ALREADY landed
its terminal state — no mid-flight ``running`` window to race.
(The in-flight ``running`` shape itself is pinned at the runner
level in ``test_background_run_starts_with_the_archive_on_disk``
and ``test_mid_run_status_carries_no_file_progress``.)"""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(upload_client, "homelab.tar.gz", _targz_bytes({"alpha.md": "# Alpha\nx\n"}))
assert r.status_code == 202, r.text
# The 202 body names the safe source — and nothing else (no counts,
# no state): the outcome is the status's, not the response's.
assert r.json() == {"detail": "upload received", "name": "homelab"}
# The very next status read is already terminal (the run finished
# before the 202 was even delivered).
s = upload_client.get("/api/git-sources/upload/status").json()
assert s["state"] == "success", s
assert s["detail"] == {"message": "uploaded"}
assert s["error"] is None
assert s["current_file"] is None
assert s["files_done"] == 0 and s["files_total"] == 0
assert s["started_at"] is not None and s["finished_at"] is not None
# The folder is in place, one row, no dotfile temp, KB untouched.
assert (uploads / "homelab").is_dir()
assert [p.name for p in uploads.iterdir()] == ["homelab"]
assert _count_rows(db) == 1
_kb_untouched(db, upload_client)
def test_background_run_starts_with_the_archive_on_disk(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A2 pinned at the runner level: the background run receives the
archive SAFELY ON DISK — parked inside unpack (before the real
unpack and the post-unpack unlink), the dotfile temp upload file
still exists, and the module-level status (what
``GET /upload/status`` reads) is ``running`` with no progress yet
(phase 90 A2: the upload has no file-level progress at all). After
release the run completes for real (real unpack + swap) and no
dotfile temp survives.
``_run_upload`` is driven directly on a worker loop (the house
background-task pattern from ``test_sync_button.py``) — the park is
a synchronous block, which would also stall the 202's response
delivery through HTTP, so the runner-level observation is the
deterministic one."""
uploads = tmp_path / "uploads"
uploads.mkdir()
_point_at(monkeypatch, uploads)
class _DummySession:
def scalar(self, statement: object) -> None:
return None
def add(self, row: object) -> None:
pass
def commit(self) -> None:
pass
def close(self) -> None:
pass
monkeypatch.setattr(git_sources_api, "SessionLocal", _DummySession)
# A real, valid archive in the temp upload file — exactly what the
# handler leaves behind before the 202.
real_archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
temp_upload = uploads / ".homelab.0.upload"
temp_unpack = uploads / ".homelab.0.unpack"
temp_upload.write_bytes(real_archive.read_bytes())
# The handler holds the flag when it creates the task — simulate.
git_sources_api._upload_in_progress = True
started = threading.Event()
release = threading.Event()
monkeypatch.setattr(git_sources_api, "unpack_archive", _GatedUnpack(started, release))
errors: list[BaseException] = []
def _run() -> None:
try:
asyncio.run(
git_sources_api._run_upload(
"homelab",
"homelab.tar.gz",
len(real_archive.read_bytes()),
uploads,
temp_upload,
temp_unpack,
)
)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
assert started.wait(15.0), "the run never reached its unpack"
# The archive is on disk while the run is in flight (A2): the
# temp upload file exists — the run is parked before the real
# unpack and the post-unpack unlink.
assert temp_upload.is_file()
# The module-level status (what GET /upload/status reads) is
# running, with no progress (phase 90 A2).
status = git_sources_api._upload_status
assert status.state == "running"
assert status.current_file is None
assert status.files_done == 0 and status.files_total == 0
assert status.detail == {} and status.error is None
assert status.started_at is not None and status.finished_at is None
release.set()
thread.join(20)
finally:
release.set()
assert not thread.is_alive()
assert errors == [], f"the run raised: {errors!r}"
terminal = git_sources_api._upload_status
assert terminal.state == "success"
assert terminal.detail == {"message": "uploaded"}
assert terminal.current_file is None
assert terminal.files_done == 0 and terminal.files_total == 0
# The folder is in place and no dotfile temp survived the run.
assert [p.name for p in uploads.iterdir()] == ["homelab"]
assert git_sources_api._upload_in_progress is False
# --- mid-run status: no file progress (phase 90 A2) ---------------------------
@pytest.mark.parametrize(
"swap_error",
[
pytest.param(None, id="success-terminal"),
pytest.param(
OSError(
"rename of https://u:p@aipi.example.com/x "
"failed: connection refused"
),
id="failed-terminal",
),
],
)
def test_mid_run_status_carries_no_file_progress(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
db: Session,
swap_error: BaseException | None,
) -> None:
"""Mid-run: the status carries NO file progress — ``current_file``
null, ``files_done`` / ``files_total`` 0/0 — for the WHOLE upload
run (phase 90 A2: the live file label belongs to the sync); the
endpoint (observed from a second client on its own loop while the
run is parked in its synchronous unpack gate) reports ``running``
with empty ``detail`` / null ``error``. Terminal states keep 0/0:
``success`` carries the no-count ``{"message": "uploaded"}``
payload; ``failed`` carries the sanitized error (credentials
masked).
``_run_upload`` is driven directly on a worker loop (the house
background-task pattern from ``test_sync_button.py``) — the park is
a synchronous block, so the endpoint observation must come from a
client with its own loop.
"""
uploads = tmp_path / "uploads"
uploads.mkdir()
_point_at(monkeypatch, uploads)
archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
temp_upload = uploads / ".homelab.0.upload"
temp_unpack = uploads / ".homelab.0.unpack"
temp_upload.write_bytes(archive.read_bytes())
started = threading.Event()
release = threading.Event()
monkeypatch.setattr(git_sources_api, "unpack_archive", _GatedUnpack(started, release))
if swap_error is not None:
def failing_swap(new_dir: Path, final_dir: Path) -> None:
raise swap_error
monkeypatch.setattr(git_sources_api, "swap_in", failing_swap)
# The handler holds the flag when it creates the task — simulate.
git_sources_api._upload_in_progress = True
errors: list[BaseException] = []
def _run() -> None:
try:
asyncio.run(
git_sources_api._run_upload(
"homelab", "homelab.tar.gz", len(archive.read_bytes()),
uploads, temp_upload, temp_unpack,
)
)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
assert started.wait(15.0), "the run never reached its unpack"
# While the run is parked: no file progress (phase 90 A2) — the
# keys exist in the key set but are null/0/0 — and the endpoint
# (a second client: the run's loop is blocked in the park)
# reports running with no detail/error yet.
with _second_client() as second:
s = second.get("/api/git-sources/upload/status").json()
assert s["state"] == "running"
assert s["current_file"] is None
assert s["files_done"] == 0 and s["files_total"] == 0
assert s["error"] is None and s["detail"] == {}
assert s["started_at"] is not None and s["finished_at"] is None
release.set()
thread.join(20)
finally:
release.set()
assert not thread.is_alive()
assert errors == [], f"the run raised: {errors!r}"
# Terminal (read from the same object the endpoint serializes):
# still no file progress; the state carries the payload.
terminal = git_sources_api._upload_status
assert terminal.current_file is None
assert terminal.files_done == 0 and terminal.files_total == 0
assert terminal.finished_at is not None
if swap_error is None:
assert terminal.state == "success", terminal
assert terminal.error is None
assert terminal.detail == {"message": "uploaded"}
# The endpoint agrees (same object, same key set).
with _second_client() as second:
s = second.get("/api/git-sources/upload/status").json()
assert s["state"] == "success" and s["detail"] == {"message": "uploaded"}
_kb_untouched(db, _admin_client())
else:
assert terminal.state == "failed", terminal
assert terminal.detail == {}
error = terminal.error or ""
assert "*****@aipi.example.com" in error # credentials masked
assert "u:p" not in error
assert "connection refused" in error # the reason survives
# A5: a pre-swap failure leaves no folder, no row, no KB state.
assert not (uploads / "homelab").exists()
assert _count_rows(db) == 0
_kb_untouched(db, _admin_client())
# --- cancellation: the finally cleans temps + releases the flag --------------
def test_cancelled_run_cleans_temps_and_releases_the_flag(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""App shutdown cancels the background task: ``CancelledError`` is
deliberately NOT caught (the ``_run_sync`` rule — swallowing it
would mask a real stop), and the ``finally`` still runs: both
temps are gone and the flag is released, so nothing lingers after
the stop.
Phase 90: the run is a synchronous pipeline (unpack → swap → row
upsert — no await), so a cancel that lands mid-run is delivered
when the run completes (CPython marks a task cancelled when a
cancel was requested before completion) — ``await task`` still
raises ``CancelledError``, exactly as before. ``_run_upload`` is
driven directly on a worker loop (the house background-task
pattern from ``test_sync_button.py``); the park is a synchronous
gate in ``swap_in``, and the cancel comes from the test's own
thread (``Task.cancel`` only sets a flag — safe across threads
while the task is blocked in synchronous code)."""
uploads = tmp_path / "uploads"
uploads.mkdir()
_point_at(monkeypatch, uploads)
monkeypatch.setattr(git_sources_api, "unpack_archive", lambda a, t, cap: None)
started = threading.Event()
release = threading.Event()
def gated_swap(new_dir: Path, final_dir: Path) -> None:
# The no-op swap: the park itself is the seam (the real swap
# would finish the run before the cancel could land).
started.set()
release.wait(30.0)
monkeypatch.setattr(git_sources_api, "swap_in", gated_swap)
class _DummySession:
def scalar(self, statement: object) -> None:
return None
def add(self, row: object) -> None:
pass
def commit(self) -> None:
pass
def close(self) -> None:
pass
monkeypatch.setattr(git_sources_api, "SessionLocal", _DummySession)
# The handler holds the flag when it creates the task — simulate.
git_sources_api._upload_in_progress = True
temp_upload = uploads / ".homelab.0.upload"
temp_unpack = uploads / ".homelab.0.unpack"
temp_upload.write_bytes(b"compressed bytes")
temp_unpack.mkdir()
(temp_unpack / "alpha.md").write_text("# A\n", encoding="utf-8")
task_holder: list[asyncio.Task[None]] = []
errors: list[BaseException] = []
def _run() -> None:
async def _main() -> None:
task = asyncio.create_task(
git_sources_api._run_upload(
"homelab", "homelab.tar.gz", 4, uploads, temp_upload, temp_unpack
)
)
task_holder.append(task)
await task
try:
asyncio.run(_main())
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
for _ in range(200):
if task_holder:
break
time.sleep(0.01)
assert task_holder, "the run task was never created"
assert started.wait(15.0), "the run never reached its swap"
# The app-shutdown shape: cancel the in-flight task. The run is
# blocked in synchronous code, so the cancel is delivered when
# it completes — still delivered (not swallowed).
task_holder[0].cancel()
# Free the gated swap AFTER the cancel: the run finishes, the
# finally cleans up, the cancel lands on the completed task.
release.set()
thread.join(20)
finally:
release.set() # backstop — the run may already be past the gate
assert not thread.is_alive()
# The CancelledError propagated (not swallowed) — and the finally
# did its cleanup: no temp survives, the flag is released.
assert len(errors) == 1
assert isinstance(errors[0], asyncio.CancelledError)
assert not temp_upload.exists()
assert not temp_unpack.exists()
assert git_sources_api._upload_in_progress is False
# --- in-flight run must not block a concurrent TRUNCATE (regression, --------
# --- phase 49 task 03; now structural in phases 64/90) -----------------------
def _admin_client() -> TestClient:
"""A one-shot admin client (no portal lifetime to manage) for
terminal-state observations after a direct-drive run."""
client = TestClient(fastapi_app)
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return client
def test_in_flight_upload_does_not_block_a_concurrent_truncate(
monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""While the background run is in flight, a concurrent TRUNCATE of
the KB + registry tables (the E2E isolation-fixture pattern) must
complete.
Regression for the phase-49 task-03 deadlock, now structural
(phases 64/90): the handler no longer touches the DB at all, the
run's row upsert commits in its own short-lived session (closed
before the run ends), and the run holds NO session across a scan —
the scan itself moved to the sync button (phase 90). This pins it:
with the run held in flight (driven directly on a worker loop,
parked inside its swap — the synchronous-seam contract), the
TRUNCATE completes well inside its 5 s ``lock_timeout`` (without
the fix it times out with a lock-not-available error)."""
uploads = tmp_path / "uploads"
uploads.mkdir()
_point_at(monkeypatch, uploads)
archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
temp_upload = uploads / ".homelab.0.upload"
temp_unpack = uploads / ".homelab.0.unpack"
temp_upload.write_bytes(archive.read_bytes())
started = threading.Event()
release = threading.Event()
monkeypatch.setattr(git_sources_api, "swap_in", _GatedSwap(started, release))
# The handler holds the flag when it creates the task — simulate.
git_sources_api._upload_in_progress = True
errors: list[BaseException] = []
def _run() -> None:
try:
asyncio.run(
git_sources_api._run_upload(
"homelab", "homelab.tar.gz", len(archive.read_bytes()),
uploads, temp_upload, temp_unpack,
)
)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
assert started.wait(15.0), "background run did not reach its swap"
# The E2E isolation TRUNCATE, exactly as the story fixtures run
# it — must complete while the run is held in flight.
with SessionLocal() as tr, tr.begin():
tr.execute(text("SET LOCAL lock_timeout = '5s'"))
tr.execute(
text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")
)
release.set()
thread.join(20)
finally:
release.set()
assert not thread.is_alive()
assert errors == [], f"the run raised: {errors!r}"
terminal = git_sources_api._upload_status
assert terminal.state == "success", terminal
assert terminal.detail == {"message": "uploaded"}
# The upsert's short-lived session is closed by now: one row,
# no stray temp.
assert _count_rows(db) == 1
assert [p.name for p in uploads.iterdir()] == ["homelab"]
# --- streaming cap (413) ----------------------------------------------------
def test_oversized_upload_gets_413_and_leaves_no_temp(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads, upload_max_mb=1)
# 2 MiB of incompressible bytes with a 1 MiB cap → 413 naming the
# cap (inline — the run never starts).
big = os.urandom(2 * 1024 * 1024)
r = _post(upload_client, "big.zip", big)
assert r.status_code == 413
assert "1 MiB" in r.json()["detail"]
assert _count_rows(db) == 0
assert uploads.exists() # the dir was created before streaming
assert list(uploads.iterdir()) == [] # the temp .upload file is gone
# The inline exception path released the flag: the next upload is
# NOT 409. Boundary: EXACTLY the cap is not 413 (the check is
# strictly >) — the stream completes, 202 lands, and the garbage
# bytes fail at the BACKGROUND unpack instead (a status state,
# phase 64 A5 — never an HTTP error post-202).
r = _post(upload_client, "exact.zip", os.urandom(1024 * 1024))
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert "could not unpack the archive" in (status["error"] or "")
assert list(uploads.iterdir()) == []
# --- unpack safety: failed runs leave the previous state intact ---------------
def test_zip_slip_archive_fails_the_run_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
evil = _zip(tmp_path / "safe.zip", {"../evil.txt": "pwned"})
r = _post(upload_client, "safe.zip", evil.read_bytes())
assert r.status_code == 202, r.text # on disk → the failure is a status state
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert "traversal" in (status["error"] or "")
assert not (tmp_path / "evil.txt").exists() # the escape never landed
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_tar_symlink_escape_fails_the_run_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
evil = tmp_path / "safe.tar"
with tarfile.open(evil, "w") as tf:
info = tarfile.TarInfo("link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
r = _post(upload_client, "safe.tar", evil.read_bytes())
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert "escape" in (status["error"] or "")
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_corrupt_archive_fails_the_run_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
# A truncated zip (the EOCD is cut off) is not a zip and not a tar.
good_zip = _zip(tmp_path / "good.zip", {"alpha.md": "# Alpha\nx\n"})
good_bytes = good_zip.read_bytes()
truncated = good_bytes[: len(good_bytes) // 2]
r = _post(upload_client, "safe.zip", truncated)
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert "could not unpack the archive" in (status["error"] or "")
# A zero-byte "archive" fails the same way.
r = _post(upload_client, "safe.tar", b"")
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert "could not unpack the archive" in (status["error"] or "")
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_swap_failure_fails_the_run_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""A rename failure in ``swap_in`` (OS error) → status ``failed``
with its message; the previous folder/row are untouched and no
temp survives (the run's finally cleans the temp sibling). A
failure here leaves the previous folder/row untouched (phase 64
A5); the KB was never touched by either upload (phase 90)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
def failing_swap(new_dir: Path, final_dir: Path) -> None:
raise ArchiveUploadError("could not replace the previous folder")
monkeypatch.setattr(git_sources_api, "swap_in", failing_swap)
r = _post(upload_client, "safe.tar.gz", _targz_bytes({"alpha.md": "# A\nx\n"}))
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert status["error"] == "could not replace the previous folder"
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_zero_entry_archive_fails_the_run(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""A completely empty archive (zero entries) → status ``failed``
``the archive contains no files`` — both containers — with no
folder, row, or temp file left behind (a pre-swap failure leaves
the KB/folders/rows untouched, phase 64 A5)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
empty_targz = tmp_path / "empty.tar.gz"
with tarfile.open(empty_targz, "w:gz"):
pass
r = _post(upload_client, "empty.tar.gz", empty_targz.read_bytes())
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert status["error"] == "the archive contains no files"
empty_zip = tmp_path / "empty.zip"
with zipfile.ZipFile(empty_zip, "w"):
pass
r = _post(upload_client, "empty.zip", empty_zip.read_bytes())
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert status["error"] == "the archive contains no files"
assert _count_rows(db) == 0
assert not (uploads / "empty").exists()
assert list(uploads.iterdir()) == []
def test_concurrent_row_insert_fails_the_run_with_the_path_named(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The upsert's unique-index backstop: the pre-check sees no row,
but the commit hits the unique constraint (a concurrent insert the
pre-check missed) → status ``failed`` naming the path. The folder
stays — the row exists (the concurrent one) and the next
sync/re-upload sees it (phase 64 A5); no temp survives and the
short-lived session was closed."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
class _ConcurrentInsertSession:
"""The short-lived upsert session, faked: the pre-check misses
(no row), the commit hits the unique index."""
def __init__(self) -> None:
self.closed = False
self.added: list[object] = []
def scalar(self, statement: object) -> None:
return None
def add(self, row: object) -> None:
self.added.append(row)
def commit(self) -> None:
raise IntegrityError("INSERT INTO git_sources", {}, Exception("duplicate key"))
def rollback(self) -> None:
pass
def close(self) -> None:
self.closed = True
sessions: list[_ConcurrentInsertSession] = []
def _fake_session_factory() -> _ConcurrentInsertSession:
s = _ConcurrentInsertSession()
sessions.append(s)
return s
monkeypatch.setattr(git_sources_api, "SessionLocal", _fake_session_factory)
r = _post(
upload_client,
"homelab.tar.gz",
_tarball(tmp_path / "h.tar.gz", {"alpha.md": "# Alpha\nx\n"}).read_bytes(),
)
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "failed"
assert status["error"] == (
f"a local source with this path already exists: {uploads / 'homelab'}"
)
# The folder stays (the row exists — the concurrent insert — and
# the next sync sees it); no temp survived; the session was closed.
assert (uploads / "homelab").is_dir()
assert [p.name for p in uploads.iterdir()] == ["homelab"]
assert len(sessions) == 1
assert sessions[0].added, "the run attempted the insert (the pre-check missed)"
assert sessions[0].closed
# --- happy path -------------------------------------------------------------
def test_happy_path_tar_gz(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {"alpha.md": "# Alpha\nfirst sentinel\n", "bravo.md": "# Bravo\nsecond sentinel\n"}
payload = _tarball(tmp_path / "homelab.tar.gz", files).read_bytes()
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
r = _post(upload_client, "homelab.tar.gz", payload)
assert r.status_code == 202, r.text
# 202 = the archive is on disk (phase 64 A2) — the safe name
# rides back in the ``UploadAccepted`` body (the toast moment).
assert r.json() == {"detail": "upload received", "name": "homelab"}
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["error"] is None
# Phase 90 A2: the success detail is the no-count "uploaded"
# payload — no import counts ride the upload anymore (the scan
# lives in the sync's status).
assert status["detail"] == {"message": "uploaded"}
# No file progress, ever: current_file null, counts 0/0.
assert status["current_file"] is None
assert status["files_done"] == 0 and status["files_total"] == 0
assert status["started_at"] is not None and status["finished_at"] is not None
# Unpacked under the tmp upload dir, dotfile temps cleaned up.
folder = uploads / "homelab"
assert folder.is_dir()
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == files
assert [p.name for p in uploads.iterdir()] == ["homelab"]
# One kind=local row; the NOT-NULL url column carries the path.
row = _row(db, str(folder))
assert row is not None
assert row.kind == "local"
assert row.url == str(folder)
assert row.path == str(folder)
assert _count_rows(db) == 1
# Phase 90: the upload touches NO KB state — zero documents, zero
# chunks, no overview row — and the scan machinery is gone from
# the module entirely (the sync button owns it).
_kb_untouched(db, upload_client)
for gone in ("import_sources", "check_models"):
assert not hasattr(git_sources_api, gone), f"{gone} must not be imported"
# The per-upload log line (PLAN §9 / AGENTS.md rule 10) — unpack +
# register only: no file counts, the state rides the line.
lines = [
rec.getMessage()
for rec in caplog.records
if rec.getMessage().startswith("upload: finished")
]
assert len(lines) == 1, lines
match = re.match(
r"^upload: finished name=homelab file=homelab\.tar\.gz bytes=(\d+) "
r"total_ms=\d+ state=success$",
lines[0],
)
assert match, lines[0]
assert int(match.group(1)) == len(payload) # the compressed bytes in
@pytest.mark.parametrize(
("filename", "builder"),
[
("notes.zip", "zip"),
("plain.tar", "tar"),
("tgz.tgz", "targz"),
],
)
def test_happy_path_other_accepted_formats(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
filename: str,
builder: str,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {"alpha.md": "# Alpha\nsentinel\n"}
make = {"zip": _zip, "tar": lambda p, f: _tarball(p, f, "w"), "targz": _tarball}[builder]
payload = make(tmp_path / filename, files).read_bytes()
r = _post(upload_client, filename, payload)
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
expected_name = {"notes.zip": "notes", "plain.tar": "plain", "tgz.tgz": "tgz"}[filename]
assert r.json()["name"] == expected_name # the safe name in the 202 body
# The folder is unpacked in full, one row, and the KB is untouched
# (phase 90 — the sync decides what gets indexed).
folder = uploads / expected_name
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == files
assert _count_rows(db) == 1
_kb_untouched(db, upload_client)
# --- re-upload, same name: in-place replace ---------------------------------
def test_reupload_same_name_replaces_in_place(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
v1 = {
"alpha.md": "# Alpha\nv1 content\n",
"bravo.md": "# Bravo\nv1 content\n",
"charlie.md": "# Charlie\nv1 content\n",
}
r = _post(upload_client, "homelab.tar.gz", _tarball(tmp_path / "v1.tar.gz", v1).read_bytes())
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
row_before = _row(db, str(uploads / "homelab"))
assert row_before is not None
added_at_before = row_before.added_at
# v2: the in-place-replace scenario, now observed via the SECOND
# run's status and the folder on disk (the upload no longer scans —
# phase 90).
v2 = {"alpha.md": "# Alpha\nv2 CHANGED content\n", "delta.md": "# Delta\nbrand new\n"}
r = _post(upload_client, "homelab.tar.gz", _tarball(tmp_path / "v2.tar.gz", v2).read_bytes())
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
# Exactly ONE row for the path, and its added_at survived (the
# second run's upsert left the existing row alone).
assert _count_rows(db) == 1
row_after = _row(db, str(uploads / "homelab"))
assert row_after is not None
assert row_after.id == row_before.id
assert row_after.added_at == added_at_before
# The folder holds ONLY the new archive's files (full replacement —
# no stale files from v1), and the KB stays untouched (phase 90:
# what the index does with them is the sync's call).
folder = uploads / "homelab"
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == v2
assert [p.name for p in uploads.iterdir()] == ["homelab"]
_kb_untouched(db, upload_client)
def test_non_a9_archive_is_a_valid_replacement(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""An archive with only non-importable files is a VALID
replacement (phase 90, A1): the swap happens, the row registers —
and the KB stays untouched (what the index does with the files is
the sync's call, not the upload's)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(
upload_client,
"notes.tar.gz",
_tarball(tmp_path / "a.tar.gz", {"readme.md": "# Readme\nv1\n"}).read_bytes(),
)
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
r = _post(
upload_client,
"notes.tar.gz",
_tarball(tmp_path / "b.tar.gz", {"binary.bin": "not an importable format"}).read_bytes(),
)
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status # still a valid replacement
assert status["detail"] == {"message": "uploaded"}
assert {p.name for p in (uploads / "notes").iterdir()} == {"binary.bin"}
assert _count_rows(db) == 1
_kb_untouched(db, upload_client)
# --- phase 89: re-uploads preserve the row's saved ignore list ---------------
def test_reupload_preserves_saved_ignore_list(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""Phase 89/90: a re-upload of an EXISTING source leaves the row —
and its saved ignore list — exactly in place (the upsert is
leave-as-is for existing rows). Phase 90: the upload no longer
scans, so the list's effect on the KB is the SYNC's (its suites
prove the honored-ignore import; the phase-90 E2E proves the full
upload → edit list → sync loop) — the upload's contract is
preservation + every file kept on disk: the ignore is about the
index, not the folder."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"keep.md": "# Keep\nin scope\n",
"ignore/secret.md": "# Secret\nignored\n",
}
r = _post(upload_client, "docs.tar.gz", _targz_bytes(files))
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
# The fresh row has no list yet — and nothing is indexed (phase 90).
folder = uploads / "docs"
row = _row(db, str(folder))
assert row is not None
assert (row.ignore_paths or []) == []
_kb_untouched(db, upload_client)
# Save the ignore list on the row via the API (phase 89, task 03).
r = upload_client.patch(
f"/api/git-sources/{row.id}", json={"ignore_paths": ["ignore/"]}
)
assert r.status_code == 200, r.text
assert r.json()["ignore_paths"] == ["ignore"] # stored normalized
# Re-upload the SAME archive (same name → in-place replace).
r = _post(upload_client, "docs.tar.gz", _targz_bytes(files))
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
# The re-upload left the row's list in place (the existing row is
# untouched by the upsert), and the folder keeps every file.
row_after = _row(db, str(folder))
assert row_after is not None
assert row_after.ignore_paths == ["ignore"]
assert {p.name for p in folder.iterdir()} == {"keep.md", "ignore"}
assert _count_rows(db) == 1
_kb_untouched(db, upload_client)
def test_upload_new_source_registers_row_with_empty_ignore_list(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""Phase 89/90 regression: an upload of a NEW source name (no row
yet, hence no ignore list) registers the row with the
server-default empty list and keeps every file on disk — including
nested files — exactly as before; nothing is indexed until the
owner presses "Sync sources" (phase 90)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
files = {
"alpha.md": "# Alpha\nroot file\n",
"sub/deep.md": "# Deep\nnested file\n",
}
r = _post(upload_client, "fresh.tar.gz", _targz_bytes(files))
assert r.status_code == 202, r.text
status = _wait_status(upload_client)
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}
# The fresh row carries the server-default empty list: nothing was
# ignored.
folder = uploads / "fresh"
row = _row(db, str(folder))
assert row is not None
assert (row.ignore_paths or []) == []
# Every file (incl. nested) is on disk — the KB is untouched.
assert (
{p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file()} == set(files)
)
_kb_untouched(db, upload_client)