zotero-kb/tests/test_api.py
Saberlve cf7e2feb19 feat: Implement project management features including rename and delete functionality
- Added project renaming capability in the workspace with appropriate API endpoints.
- Implemented project deletion functionality, ensuring project directories are removed.
- Updated UI to support project renaming and deletion, including inline editing and confirmation dialogs.
- Enhanced batch generation feature for project items with selection and progress tracking.
- Added tests for project renaming and deletion to ensure functionality and integrity.
2026-04-21 16:55:42 +08:00

393 lines
14 KiB
Python

import json
from pathlib import Path
from fastapi.testclient import TestClient
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app
from zotero_kb.api import GenerateCardsRequest
from zotero_kb.config import AppConfig
class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"])
return {
"summary": f"Summary for {title}",
"core_claims": [f"{title} supports scoped retrieval."],
"methods": ["Method details."],
"evidence": ["Evidence details."],
"quotable_passages": [f"{title} supports scoped retrieval."],
"writing_hints": ["Use as support."],
"keywords": ["retrieval", "writing"],
}
def make_test_config(tmp_path: Path) -> AppConfig:
zotero_dir = tmp_path / "zotero"
zotero_dir.mkdir()
build_fixture_zotero_dir(zotero_dir)
bridge_file = tmp_path / "bridge.json"
bridge_file.write_text('{"selected_keys": ["PAPER0001"]}', encoding="utf-8")
return AppConfig(
workspace_dir=tmp_path / "workspace",
zotero_data_dir=zotero_dir,
bridge_file=bridge_file,
)
def _route(app, path: str, method: str):
for route in app.routes:
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"Route {method} {path} not found")
def test_create_project_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/projects", "POST")
response = endpoint(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
assert response["id"] == "thesis-ch2"
def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
rename_project = _route(app, "/api/projects/{project_id}", "PATCH")
list_projects = _route(app, "/api/projects", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Old Name",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name"))
assert response["id"] == "thesis-ch2"
assert response["name"] == "New Name"
assert list_projects()[0]["name"] == "New Name"
def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
delete_project = _route(app, "/api/projects/{project_id}", "DELETE")
list_projects = _route(app, "/api/projects", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
response = delete_project("thesis-ch2")
assert response["deleted"] == "thesis-ch2"
assert list_projects() == []
def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
client = TestClient(app)
create_response = client.post(
"/api/projects",
json={
"project_id": "thesis-ch2",
"name": "Thesis Chapter 2",
"llm_provider": "openai",
"llm_model": "gpt-5-mini",
},
)
assert create_response.status_code == 201
delete_response = client.post("/api/projects/thesis-ch2/delete")
assert delete_response.status_code == 200
assert delete_response.json() == {"deleted": "thesis-ch2"}
assert client.get("/api/projects").json() == []
def test_import_selected_items_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_selected = _route(app, "/api/projects/{project_id}/imports/selected-items", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
response = import_selected("thesis-ch2")
assert response["imported_item_keys"] == ["PAPER0001"]
def test_recommend_citations_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_selected = _route(app, "/api/projects/{project_id}/imports/selected-items", "POST")
recommend = _route(app, "/api/projects/{project_id}/writing/recommend-citations", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
import_selected("thesis-ch2")
response = recommend(
"thesis-ch2",
WritingPromptRequest(prompt="support scoped retrieval during drafting"),
)
assert response["results"][0]["item_key"] == "PAPER0001"
def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/projects", "POST")
endpoint(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="deepseek",
llm_model="deepseek-chat",
)
)
project_payload = json.loads(
(tmp_path / "workspace" / "projects" / "thesis-ch2" / "project.json").read_text(encoding="utf-8")
)
assert project_payload["llm"]["provider"] == "deepseek"
assert project_payload["llm"]["model"] == "deepseek-chat"
def test_search_library_items_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
search_items = _route(app, "/api/zotero/items", "GET")
payload = search_items("research", 10)
assert payload["items"][0]["item_key"] == "PAPER0001"
def test_collection_tree_endpoint(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/zotero/collections/tree", "GET")
payload = endpoint()
assert payload["collections"][0]["collection_key"] == "COLL0001"
assert payload["collections"][0]["children"][0]["collection_key"] == "COLL0002"
def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
endpoint = _route(app, "/api/zotero/collections/{collection_key}/items", "GET")
payload = endpoint("COLL0001", True)
assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["collection_key"] == "COLL0001"
def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="deepseek",
llm_model="deepseek-chat",
)
)
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
assert payload["imported_item_keys"] == ["PAPER0001"]
def test_import_state_endpoint_shows_pending_when_no_card(tmp_path: Path) -> None:
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_state = _route(app, "/api/projects/{project_id}/import-state", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
# Manually write selected-items.json to simulate items added but no card generated
project_dir = tmp_path / "workspace" / "projects" / "thesis-ch2"
(project_dir / "selected-items.json").write_text('["PAPER0001"]', encoding="utf-8")
payload = import_state("thesis-ch2")
assert payload["project_id"] == "thesis-ch2"
assert len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "pending"
assert payload["pending_count"] == 1
assert payload["done_count"] == 0
def test_import_state_endpoint_shows_pending_after_import(tmp_path: Path) -> None:
"""After import_item_keys, card_status should be pending (cards generated separately)."""
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
import_state = _route(app, "/api/projects/{project_id}/import-state", "GET")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
payload = import_state("thesis-ch2")
assert payload["project_id"] == "thesis-ch2"
assert len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "pending"
assert payload["pending_count"] == 1
assert payload["done_count"] == 0
def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None:
"""import_item_keys should only register items, not generate cards."""
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
# Verify item was added to selected-items.json
selected_items_path = tmp_path / "workspace" / "projects" / "thesis-ch2" / "selected-items.json"
selected_items = json.loads(selected_items_path.read_text(encoding="utf-8"))
assert "PAPER0001" in selected_items
# Verify item metadata was written to items.json
items_index_path = tmp_path / "workspace" / "library" / "index" / "items.json"
items_index = json.loads(items_index_path.read_text(encoding="utf-8"))
assert "PAPER0001" in items_index
assert items_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
# Verify NO card was generated (cards.json should not have PAPER0001)
cards_index_path = tmp_path / "workspace" / "library" / "index" / "cards.json"
if cards_index_path.exists():
cards_index = json.loads(cards_index_path.read_text(encoding="utf-8"))
assert "PAPER0001" not in cards_index
else:
# File not existing means no cards were generated, which is expected
pass
def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None:
"""cards/generate endpoint should generate cards for items that have pending status."""
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
import_state = _route(app, "/api/projects/{project_id}/import-state", "GET")
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
# Import item keys first (writes to items.json but doesn't generate cards)
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
# Verify card_status is pending before generation
state_before = import_state("thesis-ch2")
assert state_before["items"][0]["card_status"] == "pending"
# Call generate endpoint
response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
# Verify response structure
assert response["project_id"] == "thesis-ch2"
assert response["generated"] == ["PAPER0001"]
assert response["failed"] == []
assert len(response["items"]) == 1
assert response["items"][0]["item_key"] == "PAPER0001"
assert response["items"][0]["card_status"] == "done"
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
config = make_test_config(tmp_path)
config.bridge_file.unlink()
app = create_app(config, llm_client=FakeLlmClient())
create_project = _route(app, "/api/projects", "POST")
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
create_project(
CreateProjectRequest(
project_id="thesis-ch2",
name="Thesis Chapter 2",
llm_provider="openai",
llm_model="gpt-5-mini",
)
)
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
assert payload["project_view"]["items"][0]["card_status"] == "pending"