import json from pathlib import Path from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir from zotero_kb.api import CreateProjectRequest, 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_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"