from __future__ import annotations import pytest from yuxi.knowledge.implementations.notion import NOTION_DEFAULT_VERSION, NotionAPIError, NotionKB PAGE_ID = "page-1" DATA_SOURCE_ID = "ds-1" PAGE = { "object": "page", "id": PAGE_ID, "url": "https://www.notion.so/page-1", "created_time": "2026-01-01T00:00:00.000Z", "last_edited_time": "2026-01-02T00:00:00.000Z", "parent": {"type": "data_source_id", "data_source_id": DATA_SOURCE_ID}, "properties": { "Name": {"type": "title", "title": [{"plain_text": "Reasoning Paper"}]}, "Abstract": {"type": "rich_text", "rich_text": [{"plain_text": "Chain of thought reasoning"}]}, }, } BLOCKS = { PAGE_ID: [ { "object": "block", "id": "block-1", "type": "paragraph", "has_children": False, "paragraph": {"rich_text": [{"plain_text": "This page discusses reasoning models."}]}, }, { "object": "block", "id": "block-2", "type": "heading_2", "has_children": False, "heading_2": {"rich_text": [{"plain_text": "Evaluation"}]}, }, ] } class _FakeNotionClient: def __init__(self, token: str, notion_version: str) -> None: self.token = token self.notion_version = notion_version async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): return False async def search_pages(self, query_text: str, limit: int) -> list[dict]: del query_text, limit return [PAGE] async def query_data_source(self, data_source_id: str, limit: int) -> list[dict]: del limit assert data_source_id == DATA_SOURCE_ID return [PAGE] async def retrieve_page(self, page_id: str) -> dict: assert page_id == PAGE_ID return PAGE async def retrieve_block_children(self, block_id: str, limit: int) -> list[dict]: del limit return BLOCKS.get(block_id, []) class _FailingNotionClient(_FakeNotionClient): async def search_pages(self, query_text: str, limit: int) -> list[dict]: del query_text, limit raise NotionAPIError("boom") class _UnknownParentNotionClient(_FakeNotionClient): async def retrieve_page(self, page_id: str) -> dict: page = await super().retrieve_page(page_id) return {**page, "parent": {"type": "page_id", "page_id": "other-page"}} @pytest.fixture def notion_kb(tmp_path): kb = NotionKB(str(tmp_path)) kb.databases_meta["kb_notion"] = { "name": "notion-kb", "description": "test", "kb_type": "notion", "query_params": {"options": {}}, "metadata": { "notion_token": "token", "notion_data_source_id": DATA_SOURCE_ID, "notion_version": NOTION_DEFAULT_VERSION, }, } return kb def test_notion_create_params_config_and_validation(monkeypatch): monkeypatch.delenv("NOTION_TOKEN", raising=False) monkeypatch.delenv("NOTION_API_KEY", raising=False) config = NotionKB.get_create_params_config() keys = [option["key"] for option in config["options"]] assert keys == ["notion_token", "notion_data_source_id", "notion_version"] params = NotionKB.normalize_additional_params( { "notion_token": " token ", "notion_data_source_id": " ds-1 ", "notion_version": " 2026-03-11 ", } ) assert params == { "notion_token": "token", "notion_data_source_id": DATA_SOURCE_ID, "notion_version": NOTION_DEFAULT_VERSION, } assert "chunk_preset_id" not in params def test_notion_validation_accepts_env_token(monkeypatch): monkeypatch.setenv("NOTION_TOKEN", "env-token") params = NotionKB.normalize_additional_params({"notion_data_source_id": DATA_SOURCE_ID}) assert params["notion_token"] == "" assert params["notion_data_source_id"] == DATA_SOURCE_ID def test_notion_validation_rejects_missing_params(monkeypatch): monkeypatch.delenv("NOTION_TOKEN", raising=False) monkeypatch.delenv("NOTION_API_KEY", raising=False) with pytest.raises(ValueError, match="notion_data_source_id"): NotionKB.normalize_additional_params({"notion_token": "token"}) with pytest.raises(ValueError, match="notion_token"): NotionKB.normalize_additional_params({"notion_data_source_id": DATA_SOURCE_ID}) @pytest.mark.asyncio async def test_notion_kb_aquery_maps_pages(monkeypatch, notion_kb): monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient) result = await notion_kb.aquery("reasoning", "kb_notion") assert len(result) == 1 assert "reasoning" in result[0]["content"].lower() assert result[0]["score"] > 0 assert result[0]["metadata"]["source"] == "Reasoning Paper" assert result[0]["metadata"]["file_id"] == PAGE_ID assert result[0]["metadata"]["chunk_id"].startswith(f"{PAGE_ID}:") assert result[0]["metadata"]["notion_url"] == "https://www.notion.so/page-1" @pytest.mark.asyncio async def test_notion_open_file_content_uses_page_markdown(monkeypatch, notion_kb): monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient) result = await notion_kb.open_file_content("kb_notion", PAGE_ID, offset=0, limit=3) assert result["start_line"] == 1 assert result["end_line"] == 3 assert result["has_more_after"] is True assert "Reasoning Paper" in result["content"] @pytest.mark.asyncio async def test_notion_find_file_content_uses_page_markdown(monkeypatch, notion_kb): monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient) result = await notion_kb.find_file_content("kb_notion", PAGE_ID, ["models"], window_size=4) assert result["match_mode"] == "keyword" assert result["total_matches"] == 1 assert result["windows"] assert "reasoning models" in result["windows"][0]["content"] @pytest.mark.asyncio async def test_notion_open_file_content_rejects_unknown_parent(monkeypatch, notion_kb): monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _UnknownParentNotionClient) with pytest.raises(ValueError, match="不属于当前 Data Source"): await notion_kb.open_file_content("kb_notion", PAGE_ID) @pytest.mark.asyncio async def test_notion_kb_aquery_error_returns_empty(monkeypatch, notion_kb): monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FailingNotionClient) result = await notion_kb.aquery("reasoning", "kb_notion") assert result == []