Files
brain-of-reese/tests/e2e/test_force_push_resync.py
T
ducoterra edf6048a0d
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 150_drop_sync_result
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass.

- Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP.
- Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests).
- Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`).
- Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed.
- Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched.
- Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
2026-10-02 02:35:03 -04:00

590 lines
25 KiB
Python

"""Phase 136 (task 02) E2E (Playwright): a force push no longer breaks
the sync — the checkout resets to the head of the tracked branch.
Source: ``TODO.md:5`` — the owner's verbatim repro: "Force pushes
break brain of reese git pull. Brain of reese should always have the
head of the branch specified, so if pull fails due to force pushes it
should just reset origin/<branch>."
Run in isolation (DB must be up: ``podman compose up -d db``; git on
PATH — a documented environment prerequisite, phase 28):
uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov
The owner's exact flow is pinned end to end: sync (clone + import) →
force-push the origin (its history rewritten to an UNRELATED root
commit) → sync again → SUCCESS, and the KB holds the NEW head's
content. Pre-phase, that second sync landed ``failed``: the legacy
``git pull --ff-only`` (the ``branch=None`` env row) refused the
divergent head and the run died in the ``failed`` state with a
user-facing error. This module is the regression pin — the pin has
TEETH: against the pre-phase ``clone_or_pull`` (verified by
temporarily reverting the phase-136 change on a scratch branch, then
restoring), run 2 lands ``failed`` and the terminal-state assertion
fails, exactly as the pre-phase behavior demanded.
Per-module app env (the ``test_sync_button.py`` module-app pattern):
this module's app boots on its own port (a same-port second uvicorn
dies on bind and would drive the wrong server) with
``BOR_GIT_SOURCES=file://<fixture repo>`` (an env-fallback git row —
``branch=None``, the default-branch path the owner's repro exercises;
the registry is truncated per test, so the fallback is the effective
source list) and a dedicated ``BOR_SOURCES_DIR``. The session app (no
git sources) is never started in this isolated run, so no port clash.
The fixture (module-scoped, real ``git`` — the
``test_sync_button.sync_git_repo`` pattern): one branch (``main``),
one commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``.
``_force_push_new_head`` rewrites the origin IN PLACE to a NEW
ORPHAN root commit (the branch ref deleted and recreated at the new
commit — full divergence, not just a new commit on top): the
``file://`` equivalent of a force push, whose OBSERVABLE contract is
that the origin's single branch now points at a commit with UNRELATED
history (no common ancestor with the old head — asserted) and the new
content. The app's next ``git fetch`` sees the divergent head.
Test → contract mapping (two tests):
1. ``test_sync_survives_a_force_push`` — the owner's exact flow,
through the UI: run 1 (clone + import: "1 added", the catalog
holds ``doc.md``) → the force push (V2) → run 2 (``state ==
"success"`` — NOT ``failed``; the UI settles to "Synced HH:MM"
with the error banner HIDDEN and no error modal — a reset is a
LOGGED event, not a user-facing failure; the ``#sync-progress``
box shows "0 added · 1 updated" (phase 150 — the single sync text
surface) and the document modal serves the V2
sentinel — the reset's content flowed through the import; the
counts are the secondary signal, the content is primary) → run 3
(idempotent: "0 added · 1 unchanged" — the checkout is stably at
the new head; the reset was not a one-off fluke, and "unchanged"
rules out drift back to V1, which would have re-reported as
"updated").
2. ``test_failed_pull_is_not_the_outcome`` — the negative pin at the
API seam (the task's PREFERRED direct-API leg, kept as its own
test rather than folded into test 1 as step 4b: it costs one
small API-only sync against the same module app — cheap while the
fixture exists — and keeps the UI test focused): a FRESH force
push (V3 — its own orphan root: test 1's checkout already sits at
V2, so a second rewrite is needed to make THIS sync the
post-force-push one) → ``POST /api/sync`` (202) + poll →
``state == "success"`` (the same contract as run 2 without the
UI), and the KB row's content carries the V3 sentinel. Purely the
status endpoint + the DB: the UI's 2 s poll loop starts only on a
click or a boot-while-running, so it does NOT adopt an
API-triggered run (the house rule — the status object, not the
request, is authoritative — the test_sync_button API-trigger
pattern asserts the endpoint, never the label).
Wall-time guard: the whole module < 120 s (app boot + four small
syncs against the mock LLM — no ``slow_llm`` proxy needed: the point
is the TERMINAL state, not a running window).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import time
import uuid
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_FPPUSH", "8135"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The fixture repo's directory name — the source name the import
#: records for it (the repo_name basename rule, phase 28).
FIXTURE_NAME = "fppush-fixture"
FIXTURE_DOC = "notes/doc.md"
#: The per-version sentinels inside the fixture doc — the V2/V3
#: sentinels in the KB after their force push are the reset's
#: observable end-to-end effect (the NEW head's content is what the
#: KB serves).
SENTINEL_V1 = "RESE-FPPUSH-V1-5e8a"
SENTINEL_V2 = "RESE-FPPUSH-V2-2b7f"
SENTINEL_V3 = "RESE-FPPUSH-V3-4a9d"
V1_DOC = (
"# Force-push fixture note\n"
"\n"
"One small note that exists only to pin the force-push resync end\n"
"to end: a real git clone, a real import, a force-pushed origin,\n"
"and the reset back to the head of the tracked branch.\n"
"\n"
f"Marker: {SENTINEL_V1}\n"
)
V2_DOC = (
"# Force-push fixture note (rewritten root)\n"
"\n"
"The origin's history was rewritten to a NEW orphan root commit —\n"
"the old commit is gone and the histories share no ancestor. The\n"
"next sync must reset to this head, not fail.\n"
"\n"
f"Marker: {SENTINEL_V2}\n"
)
V3_DOC = (
"# Force-push fixture note (rewritten root, second time)\n"
"\n"
"A second unrelated rewrite — the API-level resync pin needs THIS\n"
"sync to be the post-force-push one, so the origin gets a fresh\n"
"orphan root again.\n"
"\n"
f"Marker: {SENTINEL_V3}\n"
)
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute.
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
#: The module's wall budget (the task's wall-time guard): app boot +
#: four small syncs against the mock LLM stay under 120 s. A breach is
#: a regression canary (the sync pipeline slowed), not a flake
#: tolerance.
WALL_BUDGET_S = 120.0
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _git(cwd: Path, *args: str) -> None:
"""Run git in *cwd*; a non-zero exit fails the fixture loudly."""
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
if proc.returncode != 0:
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
def _rev_parse(repo: Path, *args: str) -> str:
"""``git rev-parse`` in *repo* — stripped stdout, loud on failure."""
proc = subprocess.run(
["git", "rev-parse", *args], cwd=repo, capture_output=True, text=True
)
if proc.returncode != 0:
raise AssertionError(
f"git rev-parse {' '.join(args)} failed: {proc.stderr.strip()}"
)
return proc.stdout.strip()
def _commit(repo: Path, message: str) -> None:
"""A fixture commit (never signed — the house ``commit.gpgsign``
flag, the test_sync_button.py idiom)."""
_git(
repo,
"-c", "user.email=e@x", "-c", "user.name=t",
"-c", "commit.gpgsign=false",
"commit", "-qm", message,
)
@pytest.fixture(scope="module")
def fppush_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A real one-commit, one-branch git repo the sync must clone
(the ``test_sync_button.sync_git_repo`` pattern): ``main``, one
commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``.
Built under ``tmp_path_factory`` (``tmp_path`` is function-scoped
while the module-scoped app fixture needs the repo for the
module's lifetime) via real ``git`` subprocesses. ``init -b
main`` pins the default branch NAME (the system's
``init.defaultBranch`` may differ).
"""
root = tmp_path_factory.mktemp("bor_fppush")
repo = root / FIXTURE_NAME
(repo / "notes").mkdir(parents=True)
(repo / "notes" / "doc.md").write_text(V1_DOC, encoding="utf-8")
_git(repo, "init", "-q", "-b", "main")
_git(repo, "add", "-A")
_commit(repo, "one")
assert (repo / ".git").is_dir()
return repo
@pytest.fixture(scope="module")
def app_server(mock_llm: int, fppush_repo: Path) -> Iterator[str]:
"""The real app under test — per-module env: the sync's subject is
a real ``file://`` git source (env-fallback row — ``branch=None``,
the default-branch path the owner's repro exercises) with its own
checkout dir (the conftest session app boots without
``BOR_GIT_SOURCES`` and is never started in this isolated run)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — keeps every story
# suite's deterministic gate behavior; production default stays 0.62.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# This suite's subject: one real local repo via the env fallback
# (the registry is truncated per test, so the fallback is the
# effective source list — the test_sync_button.py idiom).
env["BOR_GIT_SOURCES"] = f"file://{fppush_repo}"
env["BOR_SOURCES_DIR"] = str(fppush_repo.parent / "checkouts")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_kb() -> None:
"""Fresh KB + registry per test (the E2E isolation pattern): each
test's sync counts are its own. ``git_sources`` too — the shared
Postgres may carry leftover registry rows from other suites, and
``effective_sources`` prefers the DB list over this app's
``BOR_GIT_SOURCES`` env fallback. ``sources_meta`` (the KB
generation counter) resets with the KB — the bump is an upsert, so
the sync re-creates the row."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta"
))
db.commit()
@pytest.fixture(autouse=True)
def _clean_kb(db_ready: None) -> Iterator[None]:
_truncate_kb()
yield
_truncate_kb()
@pytest.fixture(scope="module", autouse=True)
def _wall_time_guard() -> Iterator[None]:
"""The module's wall-time guard (the task's 120 s budget): the
whole module — app boot + four small syncs of a one-file repo
against the mock LLM — stays under 120 s. A breach is a
regression canary (the sync pipeline slowed), not a flake
tolerance."""
start = time.monotonic()
yield
elapsed = time.monotonic() - start
assert elapsed < WALL_BUDGET_S, (
f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall "
"budget (the small-fixture syncs against the mock LLM are a "
"speed canary)"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _force_push_new_head(repo: Path, content: str) -> None:
"""Rewrite the origin's history to a NEW root commit (a full
divergence — not just a new commit on top): delete the branch
ref and recreate it at a new orphan commit with *content*.
The ``file://`` equivalent of a force push: the app's next
``git fetch`` sees the divergent head. The OBSERVABLE contract is
asserted here — the branch now points at a commit with UNRELATED
history (no ``merge-base`` with the old head) and the new
content. The fixture repo is left with EXACTLY ONE branch (the
original, at the new root), so the helper is repeatable (the
second test force-pushes again).
"""
branch = _rev_parse(repo, "--abbrev-ref", "HEAD")
old_head = _rev_parse(repo, "HEAD")
# A unique orphan branch name per call (a repeatable helper must
# not collide with a leftover ref); it is deleted again at the end
# so the fixture is left with exactly one branch.
orphan = f"fppush-root-{uuid.uuid4().hex[:8]}"
_git(repo, "checkout", "-q", "--orphan", orphan)
(repo / "notes" / "doc.md").write_text(content, encoding="utf-8")
_git(repo, "add", "-A")
_commit(repo, "fppush: rewritten root")
new_head = _rev_parse(repo, "HEAD")
# The force push proper: the branch ref is DELETED and recreated
# at the orphan root — the old history is no longer reachable from
# any ref (a real force push's observable effect).
_git(repo, "update-ref", "-d", f"refs/heads/{branch}")
_git(repo, "update-ref", f"refs/heads/{branch}", new_head)
_git(repo, "checkout", "-q", "-B", branch, new_head)
_git(repo, "update-ref", "-d", f"refs/heads/{orphan}")
# The OBSERVABLE contract: the branch at the new head, and
# UNRELATED history (no common ancestor with the old root).
assert _rev_parse(repo, branch) == new_head
proc = subprocess.run(
["git", "merge-base", old_head, new_head], cwd=repo,
capture_output=True, text=True,
)
assert proc.returncode != 0, (
"the rewrite must be UNRELATED history (no common ancestor); "
f"merge-base found: {proc.stdout.strip()!r}"
)
def _go_top(page: Page) -> None:
"""Back to the catalog's top level (no-op when already there —
the breadcrumb is hidden at the top, the test_sync_button.py
idiom)."""
if not page.locator("#kb-crumb").is_hidden():
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _expect_fixture_row(page: Page) -> None:
"""The REAL clone was imported — the fixture path is listed. The
catalog is the drill-down tree (phase 97), so drill source →
folder ``notes`` first (the ``test_sync_button._expect_fixture_row``
pattern)."""
_go_top(page)
page.click(f'#folders-tbody a.folder-link:text-is("{FIXTURE_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
def _started_at(body: dict[str, Any]) -> float:
"""The status body's ``started_at`` as a wall-clock epoch (the
server stamps it in UTC; this process runs on the same host —
same clock, the test_git_source_branch idiom)."""
started = body.get("started_at")
if not started:
return 0.0
try:
return datetime.fromisoformat(str(started)).timestamp()
except ValueError:
return 0.0
def _wait_sync_state(
page: Page,
app_url: str,
*,
clicked_at: float,
timeout_s: float = 90.0,
settle_ui: bool = True,
) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the
current run reaches a TERMINAL state, then settle the UI.
The ``test_sync_button._wait_sync_done`` pattern — the terminal
set is ``{"success", "failed"}`` (a cancel is never issued in this
module) — plus the UI-level settle wait on ``#sync-label``
(``settle_ui=True``): a success waits for the "Synced HH:MM"
last-result label (the UI's 2 s poll loop settles it; the
never-stale contract, §7.4), a ``failed`` terminal returns
WITHOUT settling (the label never leaves "Syncing…" on a
failure — the caller's terminal-state assertion is what pins
it). ``settle_ui=False`` (the API-triggered negative pin) skips
the label settle: the UI's poll loop starts only on a click or a
boot-while-running, so an API-triggered run is never adopted
visually — the status endpoint is the authoritative surface.
The module app is shared across the test's sequential runs, so
the PREVIOUS run's terminal lives in the app's memory until this
run supersedes it: a terminal is accepted only after a running
tick was observed or its ``started_at`` post-dates the click (the
stale-terminal freshness gate, the ``test_git_source_branch``
idiom) — the caller captures ``clicked_at`` just before the
trigger (the click or the ``POST /api/sync``).
"""
deadline = time.monotonic() + timeout_s
saw_running = False
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = page.request.get(f"{app_url}/api/sync/status")
assert r.status == 200, r.text
body = r.json()
state = body["state"]
if state == "running":
saw_running = True
elif state in ("success", "failed") and (
saw_running or _started_at(body) >= clicked_at - 1.0
):
if state == "success" and settle_ui:
expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=15_000
)
return body
time.sleep(0.25)
raise AssertionError(f"sync did not reach a terminal state: {body}")
# ---------------------------------------------------------------------------
# 1. THE owner's exact flow: sync → force push → sync again → success
# with the NEW head's content
# ---------------------------------------------------------------------------
def test_sync_survives_a_force_push(
page: Page, app_url: str, db_ready: None, fppush_repo: Path
) -> None:
"""The owner's exact reproduction, through the UI: run 1 (clone +
import) → force-push the origin (an orphan-root rewrite, V2) →
run 2 (``state == "success"`` — NOT the pre-phase ``failed``; the
error banner stays hidden and no error modal exists — a reset is
a logged event, not a user-facing failure) with the KB serving
the NEW head's content → run 3 (idempotent at the new head)."""
page.set_default_timeout(30_000)
login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible()
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# --- run 1: clone + import (the V1 head) --------------------------
clicked_at = time.time()
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
assert body["state"] == "success", body
# The fixture doc (one A9-format file) is the only change — the
# counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the drill-down tree, the test_sync_button pattern).
_expect_fixture_row(page)
# --- the force push (server-side — the test owns the origin) ------
_force_push_new_head(fppush_repo, V2_DOC)
# --- run 2: the resync — SUCCESS, the reset is not a failure ------
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal
clicked_at = time.time()
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
# THE regression pin: pre-phase, the --ff-only pull refused the
# divergent head and this terminal was "failed" (the run died in
# the failed state with a user-facing error).
assert body["state"] == "success", body
expect(btn).to_be_enabled()
# A reset is a LOGGED event, not a user-facing failure: the error
# banner stays hidden and no error modal was ever created (it is
# created on demand, so count 0 is the absence proof)…
expect(page.locator("#sync-error-banner")).to_be_hidden()
expect(page.locator(".sync-modal-backdrop")).to_have_count(0)
# …and the digest delta re-indexed the file as updated (the counts
# are the secondary signal — the content assertion below is
# primary). The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 updated")
# The KB holds the NEW head: the document modal serves the V2
# sentinel — the reset's content flowed through the import.
_expect_fixture_row(page)
doc_row = page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)
doc_row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-content")).to_contain_text(SENTINEL_V2)
page.keyboard.press("Escape")
expect(page.locator(".doc-modal")).to_be_hidden()
# --- run 3: idempotent — the checkout is stably at the new head ---
expect(btn).to_be_enabled()
clicked_at = time.time()
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
assert body["state"] == "success", body
# "1 unchanged" means the content hash did not move — drift back
# to V1 would have re-reported as "1 updated" (the no-drift pin).
# The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
# ---------------------------------------------------------------------------
# 2. The negative pin at the API seam: after a force push, POST
# /api/sync + poll → state == "success" (no UI)
# ---------------------------------------------------------------------------
def test_failed_pull_is_not_the_outcome(
page: Page, app_url: str, db_ready: None, fppush_repo: Path
) -> None:
"""The API-level negative pin (no UI — the task's PREFERRED
direct-API leg, its own test rather than folded into test 1 as
step 4b so the UI test stays focused): a FRESH force push (V3 —
its own orphan root: test 1's checkout already sits at V2, so a
second rewrite is needed to make THIS sync the post-force-push
one) → ``POST /api/sync`` (202) + poll → ``state ==
"success"`` — the same contract as run 2 without the UI — and
the KB row's content carries the V3 sentinel.
Purely the status endpoint + the DB (no page navigation, no UI
assertion): the UI's 2 s poll loop starts only on a click or a
boot-while-running, so it does NOT adopt an API-triggered run —
the status endpoint is the authoritative surface (the house
rule, and the test_sync_button API-trigger pattern)."""
r = page.request.post(
f"{app_url}/api/login", data={"password": ADMIN_PASSWORD}
)
assert r.status == 204, r.text
_force_push_new_head(fppush_repo, V3_DOC)
clicked_at = time.time()
r = page.request.post(f"{app_url}/api/sync")
assert r.status == 202, r.text
body = _wait_sync_state(
page, app_url, clicked_at=clicked_at, settle_ui=False
)
assert body["state"] == "success", body
# Fresh after the per-test truncate: the (reset) checkout's file
# is re-added from the NEW head.
assert body["detail"]["added"] == 1, body
# The NEW head's content is what the KB serves (the sentinel in
# the stored document).
with SessionLocal() as db:
content = db.execute(
text(
"SELECT content FROM documents "
"WHERE source = :src AND path = :path"
),
{"src": FIXTURE_NAME, "path": FIXTURE_DOC},
).scalar_one()
assert SENTINEL_V3 in content