Files
border-red-post-nalati/backend/tests/test_indexing.py

321 lines
14 KiB
Python

"""Versioned chunks remain evidence snapshots; publication uses real PostgreSQL locks."""
import hashlib
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy import func, select, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from test_retrieval import add_item
from app.models.indexing import Chunk, IndexPublication, IndexState, IndexVersion
from app.services import indexing
from app.services.indexing import locked_state
def build(client, **payload):
response = client.post("/api/indexes", json=payload)
assert response.status_code == 201, response.text
return response.json()
def publish(client, version, generation=0):
return client.post(
f"/api/indexes/{version['id']}/publish", json={"expected_generation": generation}
)
def chunks(client, version):
return client.get(f"/api/indexes/{version['id']}/chunks?page_size=100").json()["items"]
def test_build_preserves_original_offsets_content_hash_and_provenance(client):
content = ("A字符\n那拉提🙂占位资料。" * 37).strip()
item = add_item(client, content=content)
first = build(client, chunk_size=100, overlap=20)
second = build(client, chunk_size=100, overlap=20)
assert first["status"] == "ready" and first["item_count"] == 1
assert first["corpus_fingerprint"] == second["corpus_fingerprint"]
assert first["config"] == second["config"] and first["id"] != second["id"]
actual = chunks(client, first)
assert len(actual) == first["chunk_count"]
assert [c["ordinal"] for c in actual] == list(range(len(actual)))
assert actual[0]["start_offset"] == 0 and actual[-1]["end_offset"] == len(content)
restored = [""] * len(content)
for chunk in actual:
start, end = chunk["start_offset"], chunk["end_offset"]
assert chunk["text"] == content[start:end]
assert chunk["text_sha256"] == hashlib.sha256(chunk["text"].encode()).hexdigest()
assert chunk["provenance"]["knowledge_version"] == item["version"]
assert chunk["provenance"]["source_url"] == "https://example.com/"
restored[start:end] = list(chunk["text"])
assert "".join(restored) == content
assert [(c["text"], c["start_offset"], c["end_offset"]) for c in actual] == [
(c["text"], c["start_offset"], c["end_offset"]) for c in chunks(client, second)
]
state = client.get("/api/indexes/active").json()
assert state["generation"] == 0 and state["active_version"] is None and not state["is_current"]
def test_only_valid_reviewed_evidence_is_chunked(client):
good = add_item(client)
add_item(client, status="pending")
add_item(client, status="rejected")
removed = add_item(client)
assert (
client.delete(
f"/api/knowledge/{removed['id']}?expected_version={removed['version']}"
).status_code
== 204
)
version = build(client)
assert version["item_count"] == 1
assert {c["knowledge_id"] for c in chunks(client, version)} == {good["id"]}
def test_publication_is_atomic_optimistic_and_audited(client):
add_item(client)
first, second = build(client), build(client)
result = publish(client, first)
assert result.status_code == 200 and result.json()["generation"] == 1
assert result.json()["is_current"]
assert publish(client, second).status_code == 409
assert publish(client, second, 1).json()["generation"] == 2
assert client.get(f"/api/indexes/{first['id']}").json()["status"] == "superseded"
assert client.get(f"/api/indexes/{second['id']}").json()["status"] == "published"
# Same active version with the current generation is an idempotent no-op.
assert publish(client, second, 2).json()["generation"] == 2
history = client.get("/api/indexes/publications").json()
assert history["total"] == 2
assert history["items"][0]["previous_version_id"] == first["id"]
assert history["items"][0]["version_id"] == second["id"]
assert client.get("/api/indexes/active").json()["active_version"]["id"] == second["id"]
def test_catalog_mutation_marks_active_stale_and_rejects_old_build(client):
item = add_item(client)
version = build(client)
assert publish(client, version).status_code == 200
old = chunks(client, version)
updated = client.patch(
f"/api/knowledge/{item['id']}",
json={"expected_version": item["version"], "content": "修订的占位正文"},
)
assert updated.status_code == 200
active = client.get("/api/indexes/active").json()
assert not active["is_current"] and active["generation"] == 1
assert active["active_version"]["id"] == version["id"]
assert chunks(client, version) == old
assert publish(client, version, 1).status_code == 409
replacement = build(client)
assert replacement["item_count"] == 0 # revised body is pending, not approved evidence
assert publish(client, replacement, 1).json()["is_current"]
assert chunks(client, replacement) == []
def test_failed_catalog_write_does_not_invalidate_current_version(client):
item = add_item(client)
version = build(client)
publish(client, version)
before = client.get("/api/indexes/active").json()
assert (
client.patch(
f"/api/knowledge/{item['id']}", json={"expected_version": 999, "content": "占位"}
).status_code
== 409
)
assert client.post("/api/knowledge", json={"title": "", "content": "占位"}).status_code == 422
assert client.get("/api/indexes/active").json() == before
def test_source_and_document_mutations_preserve_old_provenance(client):
src = client.post(
"/api/sources", json={"name": "原始占位来源", "source_url": "https://example.com/"}
).json()
doc = client.post(
"/api/documents",
json={
"source_id": src["id"],
"title": "占位文档",
"content": "占位正文",
"collected_at": "2026-10-16T00:00:00Z",
},
).json()
add_item(client, document_id=doc["id"])
version = build(client)
publish(client, version)
snapshot = chunks(client, version)
assert snapshot[0]["provenance"]["document_version"] == doc["version"]
assert snapshot[0]["provenance"]["source_name"] == src["name"]
assert (
client.patch(
f"/api/documents/{doc['id']}", json={"expected_version": 1, "content": "文档新正文"}
).status_code
== 200
)
assert not client.get("/api/indexes/active").json()["is_current"]
empty = build(client)
publish(client, empty, 1)
assert (
client.patch(
f"/api/sources/{src['id']}", json={"expected_version": 1, "name": "新来源名"}
).status_code
== 200
)
assert not client.get("/api/indexes/active").json()["is_current"]
assert chunks(client, version) == snapshot
assert publish(client, version, 2).status_code == 409
def test_fingerprint_recheck_rejects_out_of_band_catalog_changes(client, migrated_database):
item = add_item(client)
version = build(client)
publish(client, version)
with migrated_database.begin() as c:
c.execute(
text("UPDATE knowledge_items SET content=:body WHERE id=:id"),
{"body": "直接修改的占位正文", "id": item["id"]},
)
assert not client.get("/api/indexes/active").json()["is_current"]
assert publish(client, version, 1).status_code == 409
@pytest.mark.parametrize("failure", ["chunk_budget", "corpus_budget", "database_write"])
def test_failed_build_keeps_previous_publication_and_leaves_no_partial_rows(
client, migrated_database, monkeypatch, failure
):
add_item(client, content="占位正文" * 100)
previous = build(client)
publish(client, previous)
if failure == "chunk_budget":
monkeypatch.setattr(indexing, "MAX_CHUNKS", 1)
response = client.post("/api/indexes", json={"chunk_size": 100, "overlap": 0})
assert response.status_code == 503
elif failure == "corpus_budget":
from app.services import retrieval
monkeypatch.setattr(retrieval, "MAX_ITEMS", 0)
assert client.post("/api/indexes", json={}).status_code == 503
monkeypatch.undo()
else:
# Real PostgreSQL constraint failure during chunk insert; the new version rolls back too.
with migrated_database.begin() as c:
c.execute(
text("ALTER TABLE chunks ADD CONSTRAINT test_reject_chunk CHECK (false) NOT VALID")
)
assert client.post("/api/indexes", json={}).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(IndexVersion)) == 1
assert db.scalar(select(func.count()).select_from(Chunk)) == previous["chunk_count"]
assert db.get(IndexState, 1).active_version_id == previous["id"]
assert db.scalar(select(func.count()).select_from(IndexPublication)) == 1
assert client.get("/api/indexes/active").json()["active_version"]["id"] == previous["id"]
@pytest.mark.parametrize(
"payload",
[
{"chunk_size": 99},
{"chunk_size": 2001},
{"overlap": -1},
{"chunk_size": 100, "overlap": 51},
{"unexpected": 1},
],
)
def test_build_validation_has_no_side_effects(client, payload):
assert client.post("/api/indexes", json=payload).status_code == 422
assert client.get("/api/indexes").json()["total"] == 0
def test_index_management_permissions_csrf_and_pagination(client, anonymous_client):
# Same TestClient fixture is authenticated as admin here; first verify authorized validation.
version = build(client)
assert client.post("/api/indexes", json={}, headers={"X-CSRF-Token": ""}).status_code == 403
assert client.get("/api/indexes?page_size=101").status_code == 422
assert client.get(f"/api/indexes/{uuid4()}").status_code == 404
assert client.get(f"/api/indexes/{uuid4()}/chunks").status_code == 404
assert client.get("/api/indexes/invalid").status_code == 422
assert client.get(f"/api/indexes/{version['id']}/chunks?page=2").json()["items"] == []
client.cookies.clear()
for method, path in [
("GET", "/api/indexes"),
("GET", "/api/indexes/active"),
("GET", "/api/indexes/publications"),
("POST", "/api/indexes"),
("GET", f"/api/indexes/{version['id']}/chunks"),
("POST", f"/api/indexes/{version['id']}/publish"),
]:
assert client.request(method, path, json={}).status_code == 401
assert (
client.post(
"/api/auth/register", json={"username": "normal_user", "password": "test-password-123"}
).status_code
== 201
)
auth = client.post(
"/api/auth/login", json={"username": "normal_user", "password": "test-password-123"}
).json()
client.headers["X-CSRF-Token"] = auth["csrf_token"]
assert client.post("/api/indexes", json={}).status_code == 403
assert client.get("/api/indexes/active").status_code == 403
def test_publication_lock_serializes_competing_publishers(client, migrated_database):
add_item(client)
first, second = build(client), build(client)
actor = first["created_by"]
with Session(migrated_database) as owner, Session(migrated_database) as competitor:
locked_state(owner)
competitor.execute(text("SET LOCAL lock_timeout = '100ms'"))
with pytest.raises(OperationalError) as blocked:
indexing.publish_index(competitor, UUID(second["id"]), 0, actor)
assert blocked.value.orig.sqlstate == "55P03"
competitor.rollback()
assert indexing.publish_index(owner, UUID(first["id"]), 0, actor).generation == 1
with pytest.raises(HTTPException) as stale:
indexing.publish_index(competitor, UUID(second["id"]), 0, actor)
assert stale.value.status_code == 409
competitor.rollback()
assert client.get("/api/indexes/publications").json()["total"] == 1
assert client.get("/api/indexes/active").json()["active_version"]["id"] == first["id"]
def test_catalog_writes_use_the_publication_lock(client, migrated_database):
# The guard must run before a catalog mutation, even for creation with no parent row.
with Session(migrated_database) as owner:
locked_state(owner)
from app.db.session import get_db
factory = client.app.dependency_overrides[get_db]
def short_timeout():
for db in factory():
db.execute(text("SET LOCAL lock_timeout = '100ms'"))
yield db
client.app.dependency_overrides[get_db] = short_timeout
response = client.post("/api/knowledge", json={"title": "占位", "content": "占位"})
assert response.status_code == 503
client.app.dependency_overrides[get_db] = factory
owner.rollback()
assert client.get("/api/knowledge").json() == []
def test_publication_failure_rolls_back_old_status_pointer_and_audit(client, migrated_database):
add_item(client)
first, second = build(client), build(client)
publish(client, first)
with migrated_database.begin() as c:
c.execute(
text(
"ALTER TABLE index_publications ADD CONSTRAINT test_reject_publication CHECK (false) NOT VALID"
)
)
assert publish(client, second, 1).status_code == 409
assert client.get("/api/indexes/active").json()["active_version"]["id"] == first["id"]
assert client.get(f"/api/indexes/{first['id']}").json()["status"] == "published"
assert client.get(f"/api/indexes/{second['id']}").json()["status"] == "ready"
assert client.get("/api/indexes/publications").json()["total"] == 1