from datetime import datetime import pytest from sqlalchemy import select from sqlalchemy.orm import Session from sqlalchemy.orm.exc import StaleDataError from app.models import KnowledgeItem COLLECTED = "2026-10-15T09:00:00+08:00" def create(client, path, data): response = client.post("/api/" + path, json=data) assert response.status_code == 201, response.text return response.json() def source(client): return create(client, "sources", {"name": "示例来源", "source_url": "https://example.com/"}) def document(client, source_id): return create( client, "documents", { "source_id": source_id, "title": "示例文档", "content": "仅用于自动化测试的占位内容", "collected_at": COLLECTED, }, ) def knowledge(client, **overrides): return create( client, "knowledge", { "title": "示例知识", "content": "测试资料,不代表文旅事实", "source_url": "https://example.com/", "collected_at": COLLECTED, **overrides, }, ) def review(client, item, status="verified"): return client.post( f"/api/knowledge/{item['id']}/review", json={ "expected_version": item["version"], "verification_status": status, "reviewer": "test-reviewer", "verification_notes": "仅验证审核流程,非真实核验结论", }, ) def test_health_docs_and_stub(client): assert client.get("/api/health").json()["status"] == "ok" assert client.get("/api/ready").json()["database"] == "connected" assert client.get("/docs").status_code == 200 assert "/api/knowledge/{item_id}/review" in client.get("/openapi.json").json()["paths"] assert client.post("/api/chat", json={"question": "测试"}).json()["status"] == "stub" def test_legacy_create_and_missing_evidence(client): item = create(client, "knowledge", {"title": "旧版调用", "content": "示例"}) assert item["verification_status"] == "pending" assert item["collected_at"] is None assert isinstance(client.get("/api/knowledge").json(), list) assert review(client, item).status_code == 422 assert client.get(f"/api/knowledge/{item['id']}/history").json()["total"] == 1 def test_optional_empty_url_matches_existing_frontend(client): item = create(client, "knowledge", {"title": "示例", "content": "示例", "source_url": ""}) assert item["source_url"] is None def test_reassign_document_resolves_new_source(client): first = source(client) first_doc = document(client, first["id"]) second = source(client) second_doc = document(client, second["id"]) item = knowledge(client, document_id=first_doc["id"]) response = client.patch( f"/api/knowledge/{item['id']}", json={ "expected_version": item["version"], "document_id": second_doc["id"], }, ) assert response.status_code == 200 assert response.json()["source_id"] == second["id"] def test_browser_preflight_and_document_source_lists(client): response = client.options( "/api/knowledge", headers={ "Origin": "http://localhost:5173", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", }, ) assert response.status_code == 200 assert response.headers["access-control-allow-origin"] == "http://localhost:5173" src = source(client) doc = document(client, src["id"]) assert client.get("/api/sources?q=示例").json()["items"][0]["id"] == src["id"] result = client.get(f"/api/documents?source_id={src['id']}&q=占位").json() assert result["total"] == 1 and result["items"][0]["id"] == doc["id"] def test_review_edit_and_history(client): item = knowledge(client) verified = review(client, item) assert verified.status_code == 200 verified = verified.json() assert verified["version"] == 2 assert datetime.fromisoformat(verified["reviewed_at"]).tzinfo is not None assert review(client, item).status_code == 409 response = client.patch( f"/api/knowledge/{item['id']}", json={ "expected_version": verified["version"], "content": "修订后的示例正文", }, ) assert response.status_code == 200 updated = response.json() assert updated["verification_status"] == "pending" assert updated["reviewer"] is None and updated["reviewed_at"] is None events = client.get(f"/api/knowledge/{item['id']}/history").json() assert [event["action"] for event in events["items"]] == ["update", "review", "create"] assert events["items"][1]["snapshot"]["item"]["verification_status"] == "verified" def test_source_document_changes_invalidate_evidence(client): src = source(client) doc = document(client, src["id"]) item = knowledge(client, source_id=src["id"], document_id=doc["id"]) assert review(client, item).status_code == 200 response = client.patch( f"/api/documents/{doc['id']}", json={ "expected_version": doc["version"], "content": "修订后的文档", }, ) assert response.status_code == 200 current = client.get(f"/api/knowledge/{item['id']}").json() assert current["verification_status"] == "pending" assert review(client, current).status_code == 200 response = client.patch( f"/api/sources/{src['id']}", json={ "expected_version": src["version"], "source_url": "https://example.com/new", }, ) assert response.status_code == 200 current = client.get(f"/api/knowledge/{item['id']}").json() assert current["verification_status"] == "pending" assert current["source_url"] == "https://example.com/new" events = client.get(f"/api/knowledge/{item['id']}/history").json()["items"] reviewed = next(e for e in events if e["action"] == "review") assert reviewed["snapshot"]["source"]["source_url"] == "https://example.com/" assert reviewed["snapshot"]["document"]["content"] == "修订后的文档" def test_delete_preserves_history_and_guards_references(client): src = source(client) doc = document(client, src["id"]) item = knowledge(client, document_id=doc["id"]) assert item["source_id"] == src["id"] assert client.delete(f"/api/sources/{src['id']}?expected_version=1").status_code == 409 assert client.delete(f"/api/documents/{doc['id']}?expected_version=1").status_code == 409 assert client.delete(f"/api/knowledge/{item['id']}?expected_version=2").status_code == 409 assert client.delete(f"/api/knowledge/{item['id']}?expected_version=1").status_code == 204 assert client.get(f"/api/knowledge/{item['id']}").status_code == 404 assert client.get("/api/knowledge").json() == [] assert ( client.get(f"/api/knowledge/{item['id']}/history").json()["items"][0]["action"] == "delete" ) assert client.delete(f"/api/documents/{doc['id']}?expected_version=1").status_code == 204 assert client.delete(f"/api/sources/{src['id']}?expected_version=1").status_code == 204 assert client.get("/api/sources").json()["total"] == 0 def test_filter_pagination_and_literal_search(client): knowledge(client, title="示例%资料", category="交通出行") other = knowledge(client, title="其他资料", category="民宿服务") assert review(client, other, "rejected").status_code == 200 page = client.get("/api/knowledge/page?page_size=1").json() assert page["total"] == 2 and len(page["items"]) == 1 assert page["items"][0]["id"] == other["id"] assert client.get("/api/knowledge/page?page=3&page_size=1").json()["items"] == [] assert client.get("/api/knowledge/page?q=%25").json()["total"] == 1 assert len(client.get("/api/knowledge?category=交通出行").json()) == 1 assert client.get("/api/knowledge/page?verification_status=verified").json()["total"] == 0 assert client.get("/api/knowledge/page?verification_status=rejected").json()["total"] == 1 @pytest.mark.parametrize( "patch", [ {"title": " "}, {"title": None}, {"content": ""}, {"category": ""}, {"collected_at": "2026-10-15T09:00:00"}, {"source_url": "javascript:alert(1)"}, {"verification_status": "verified"}, {}, ], ) def test_invalid_update_cannot_mutate_or_audit(client, patch): item = knowledge(client) response = client.patch(f"/api/knowledge/{item['id']}", json={"expected_version": 1, **patch}) assert response.status_code == 422 assert client.get(f"/api/knowledge/{item['id']}").json()["version"] == 1 assert client.get(f"/api/knowledge/{item['id']}/history").json()["total"] == 1 def test_invalid_relationship_and_query(client): src = source(client) doc = document(client, src["id"]) second = source(client) response = client.post( "/api/knowledge", json={ "title": "示例", "content": "示例", "source_id": second["id"], "document_id": doc["id"], }, ) assert response.status_code == 422 assert ( client.post( "/api/knowledge", json={"title": "示例", "content": "示例", "document_id": 9999} ).status_code == 404 ) for path in ["knowledge", "sources", "documents"]: assert client.get(f"/api/{path}/9999").status_code == 404 assert client.get(f"/api/{path}/9999/history").status_code == 404 assert client.get("/api/knowledge/page?page_size=101").status_code == 422 assert client.get("/api/knowledge/page?verification_status=unknown").status_code == 422 assert client.get("/api/knowledge/page?page=0").status_code == 422 def test_optimistic_lock_prevents_concurrent_overwrite(client, migrated_database): item = knowledge(client) with Session(migrated_database) as first, Session(migrated_database) as second: first_item = first.scalar(select(KnowledgeItem).where(KnowledgeItem.id == item["id"])) second_item = second.get(KnowledgeItem, item["id"]) first_item.content = "先提交的内容" first.commit() second_item.content = "过期编辑" with pytest.raises(StaleDataError): second.commit() second.rollback() assert client.get(f"/api/knowledge/{item['id']}").json()["content"] == "先提交的内容"