From 15709b82f73617879d88b188df4438fdfc569602 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 7 May 2026 22:21:52 +0530 Subject: [PATCH] test(backend): add Notion E2E fakes --- .../e2e/fakes/fixtures/notion_pages.json | 64 ++++++ .../tests/e2e/fakes/notion_module.py | 197 ++++++++++++++++++ surfsense_backend/tests/e2e/run_backend.py | 12 ++ surfsense_backend/tests/e2e/run_celery.py | 12 ++ 4 files changed, 285 insertions(+) create mode 100644 surfsense_backend/tests/e2e/fakes/fixtures/notion_pages.json create mode 100644 surfsense_backend/tests/e2e/fakes/notion_module.py diff --git a/surfsense_backend/tests/e2e/fakes/fixtures/notion_pages.json b/surfsense_backend/tests/e2e/fakes/fixtures/notion_pages.json new file mode 100644 index 000000000..c0056e6d8 --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/fixtures/notion_pages.json @@ -0,0 +1,64 @@ +{ + "pages": [ + { + "object": "page", + "id": "fake-notion-page-canary-001", + "created_time": "2026-05-07T00:00:00.000Z", + "last_edited_time": "2026-05-07T00:00:00.000Z", + "url": "https://notion.so/fake-notion-page-canary-001", + "properties": { + "Name": { + "id": "title", + "type": "title", + "title": [ + { + "type": "text", + "plain_text": "E2E Canary Notion Page", + "text": { + "content": "E2E Canary Notion Page" + } + } + ] + } + } + } + ], + "blocks": { + "fake-notion-page-canary-001": [ + { + "object": "block", + "id": "fake-notion-block-heading-001", + "type": "heading_2", + "has_children": false, + "heading_2": { + "rich_text": [ + { + "type": "text", + "plain_text": "E2E Notion Canary", + "text": { + "content": "E2E Notion Canary" + } + } + ] + } + }, + { + "object": "block", + "id": "fake-notion-block-body-001", + "type": "paragraph", + "has_children": false, + "paragraph": { + "rich_text": [ + { + "type": "text", + "plain_text": "This Notion page proves the indexed connector fetched Notion blocks through OAuth credentials. SURFSENSE_E2E_CANARY_TOKEN_NOTION_001", + "text": { + "content": "This Notion page proves the indexed connector fetched Notion blocks through OAuth credentials. SURFSENSE_E2E_CANARY_TOKEN_NOTION_001" + } + } + ] + } + } + ] + } +} diff --git a/surfsense_backend/tests/e2e/fakes/notion_module.py b/surfsense_backend/tests/e2e/fakes/notion_module.py new file mode 100644 index 000000000..4a7598726 --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/notion_module.py @@ -0,0 +1,197 @@ +"""Strict Notion OAuth/API fakes for Playwright E2E.""" + +from __future__ import annotations + +import json +import types +from pathlib import Path +from typing import Any +from unittest.mock import patch + +_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "notion_pages.json" +_TOKEN_URL = "https://api.notion.com/v1/oauth/token" +_ACCESS_TOKEN = "fake-notion-access-token" +_REFRESH_TOKEN = "fake-notion-refresh-token" + + +def _load_fixture() -> dict[str, Any]: + with _FIXTURE_PATH.open() as f: + return json.load(f) + + +_FIXTURE = _load_fixture() + + +class _StrictFakeMixin: + _component_name: str = "" + + def __getattr__(self, name: str) -> Any: + raise NotImplementedError( + f"E2E Notion fake missing surface: {self._component_name}.{name!r}. " + "Add it to surfsense_backend/tests/e2e/fakes/notion_module.py." + ) + + +class APIResponseError(Exception): + def __init__( + self, + message: str, + *, + status: int = 400, + code: str = "validation_error", + headers: dict[str, str] | None = None, + body: Any | None = None, + ): + super().__init__(message) + self.status = status + self.code = code + self.headers = headers or {} + self.body = body or {"message": message} + + +errors = types.ModuleType("notion_client.errors") +errors.APIResponseError = APIResponseError + + +class _FakeBlocksChildren(_StrictFakeMixin): + _component_name = "notion.blocks.children" + + async def list(self, **kwargs: Any) -> dict[str, Any]: + block_id = kwargs.get("block_id") + start_cursor = kwargs.get("start_cursor") + if start_cursor is not None: + raise NotImplementedError( + f"E2E Notion fake does not model block pagination cursor={start_cursor!r}." + ) + + blocks = _FIXTURE.get("blocks", {}).get(block_id) + if blocks is None: + raise APIResponseError( + f"Could not find block: {block_id}", + status=404, + code="object_not_found", + body={"message": f"Could not find block: {block_id}"}, + ) + return { + "object": "list", + "results": blocks, + "has_more": False, + "next_cursor": None, + } + + +class _FakeBlocks(_StrictFakeMixin): + _component_name = "notion.blocks" + + def __init__(self) -> None: + self.children = _FakeBlocksChildren() + + +class AsyncClient(_StrictFakeMixin): + _component_name = "notion.AsyncClient" + + def __init__(self, *, auth: str, **kwargs: Any): + del kwargs + if auth != _ACCESS_TOKEN: + raise ValueError(f"Unexpected fake Notion auth token: {auth!r}") + self.auth = auth + self.blocks = _FakeBlocks() + + async def search(self, **kwargs: Any) -> dict[str, Any]: + unsupported = set(kwargs) - {"filter", "sort", "start_cursor"} + if unsupported: + raise NotImplementedError( + f"E2E Notion fake search got unsupported kwargs: {sorted(unsupported)}" + ) + if kwargs.get("start_cursor") is not None: + raise NotImplementedError( + f"E2E Notion fake does not model search cursor={kwargs['start_cursor']!r}." + ) + expected_filter = {"value": "page", "property": "object"} + if kwargs.get("filter") != expected_filter: + raise NotImplementedError( + f"E2E Notion fake search expected filter={expected_filter!r}, " + f"got {kwargs.get('filter')!r}." + ) + return { + "object": "list", + "results": _FIXTURE.get("pages", []), + "has_more": False, + "next_cursor": None, + } + + async def aclose(self) -> None: + return None + + +class _FakeTokenResponse(_StrictFakeMixin): + _component_name = "notion.oauth.response" + + def __init__(self, payload: dict[str, Any], status_code: int = 200): + self._payload = payload + self.status_code = status_code + self.text = json.dumps(payload, sort_keys=True) + + def json(self) -> dict[str, Any]: + return self._payload + + +class _FakeHttpxAsyncClient(_StrictFakeMixin): + _component_name = "httpx.AsyncClient" + + async def __aenter__(self) -> _FakeHttpxAsyncClient: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + del exc_type, exc, tb + + async def post(self, url: str, **kwargs: Any) -> _FakeTokenResponse: + if url != _TOKEN_URL: + raise NotImplementedError(f"Unexpected Notion OAuth POST url={url!r}") + + data = kwargs.get("json") or {} + headers = kwargs.get("headers") or {} + if "Authorization" not in headers: + raise ValueError( + "Notion OAuth token exchange missing Authorization header." + ) + + grant_type = data.get("grant_type") + if grant_type == "authorization_code": + if data.get("code") != "fake-notion-oauth-code": + raise ValueError( + f"Unexpected fake Notion OAuth code: {data.get('code')!r}" + ) + elif grant_type == "refresh_token": + if data.get("refresh_token") != _REFRESH_TOKEN: + raise ValueError( + f"Unexpected fake Notion refresh token: {data.get('refresh_token')!r}" + ) + else: + raise ValueError(f"Unexpected fake Notion grant_type: {grant_type!r}") + + return _FakeTokenResponse( + { + "access_token": _ACCESS_TOKEN, + "refresh_token": _REFRESH_TOKEN, + "expires_in": 3600, + "workspace_id": "fake-notion-workspace-001", + "workspace_name": "SurfSense E2E Notion Workspace", + "workspace_icon": "https://surfsense.example/notion-icon.png", + "bot_id": "fake-notion-bot-001", + } + ) + + +def install(active_patches: list[Any]) -> None: + """Patch production bindings that cannot be covered by sys.modules hijack.""" + targets = [ + ( + "app.routes.notion_add_connector_route.httpx.AsyncClient", + _FakeHttpxAsyncClient, + ), + ] + for target, replacement in targets: + p = patch(target, replacement) + p.start() + active_patches.append(p) diff --git a/surfsense_backend/tests/e2e/run_backend.py b/surfsense_backend/tests/e2e/run_backend.py index a1b4f7c8a..e1704c402 100644 --- a/surfsense_backend/tests/e2e/run_backend.py +++ b/surfsense_backend/tests/e2e/run_backend.py @@ -43,8 +43,11 @@ if _BACKEND_ROOT not in sys.path: sys.path.insert(0, _BACKEND_ROOT) import tests.e2e.fakes.composio_module as _fake_composio # noqa: E402 +import tests.e2e.fakes.notion_module as _fake_notion # noqa: E402 sys.modules["composio"] = _fake_composio +sys.modules["notion_client"] = _fake_notion +sys.modules["notion_client.errors"] = _fake_notion.errors # --------------------------------------------------------------------------- @@ -54,6 +57,12 @@ sys.modules["composio"] = _fake_composio from dotenv import load_dotenv # noqa: E402 load_dotenv() +os.environ.setdefault("NOTION_CLIENT_ID", "fake-notion-client-id") +os.environ.setdefault("NOTION_CLIENT_SECRET", "fake-notion-client-secret") +os.environ.setdefault( + "NOTION_REDIRECT_URI", + "http://localhost:8000/api/v1/auth/notion/connector/callback", +) logging.basicConfig( level=logging.INFO, @@ -82,6 +91,7 @@ from app.app import app # noqa: E402 from tests.e2e.fakes import ( # noqa: E402 embeddings as _fake_embeddings, native_google as _fake_native_google, + notion_module as _fake_notion_module, ) from tests.e2e.fakes.chat_llm import ( # noqa: E402 fake_create_chat_litellm_from_agent_config, @@ -98,6 +108,7 @@ def _patch_llm_bindings() -> None: "app.services.llm_service.get_user_long_context_llm", "app.tasks.connector_indexers.google_drive_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_gmail_indexer.get_user_long_context_llm", + "app.tasks.connector_indexers.notion_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.local_folder_indexer.get_user_long_context_llm", "app.tasks.document_processors._save.get_user_long_context_llm", "app.tasks.document_processors.markdown_processor.get_user_long_context_llm", @@ -150,6 +161,7 @@ def _patch_llm_bindings() -> None: _patch_llm_bindings() _fake_embeddings.install(_active_patches) _fake_native_google.install(_active_patches) +_fake_notion_module.install(_active_patches) # --------------------------------------------------------------------------- diff --git a/surfsense_backend/tests/e2e/run_celery.py b/surfsense_backend/tests/e2e/run_celery.py index 6e442de54..c20c44cb6 100644 --- a/surfsense_backend/tests/e2e/run_celery.py +++ b/surfsense_backend/tests/e2e/run_celery.py @@ -30,8 +30,11 @@ if _BACKEND_ROOT not in sys.path: # --------------------------------------------------------------------------- import tests.e2e.fakes.composio_module as _fake_composio # noqa: E402 +import tests.e2e.fakes.notion_module as _fake_notion # noqa: E402 sys.modules["composio"] = _fake_composio +sys.modules["notion_client"] = _fake_notion +sys.modules["notion_client.errors"] = _fake_notion.errors # --------------------------------------------------------------------------- @@ -41,6 +44,12 @@ sys.modules["composio"] = _fake_composio from dotenv import load_dotenv # noqa: E402 load_dotenv() +os.environ.setdefault("NOTION_CLIENT_ID", "fake-notion-client-id") +os.environ.setdefault("NOTION_CLIENT_SECRET", "fake-notion-client-secret") +os.environ.setdefault( + "NOTION_REDIRECT_URI", + "http://localhost:8000/api/v1/auth/notion/connector/callback", +) logging.basicConfig( level=logging.INFO, @@ -67,6 +76,7 @@ from app.celery_app import celery_app # noqa: E402 from tests.e2e.fakes import ( # noqa: E402 embeddings as _fake_embeddings, native_google as _fake_native_google, + notion_module as _fake_notion_module, ) from tests.e2e.fakes.chat_llm import ( # noqa: E402 fake_create_chat_litellm_from_agent_config, @@ -82,6 +92,7 @@ def _patch_llm_bindings() -> None: "app.services.llm_service.get_user_long_context_llm", "app.tasks.connector_indexers.google_drive_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_gmail_indexer.get_user_long_context_llm", + "app.tasks.connector_indexers.notion_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.local_folder_indexer.get_user_long_context_llm", "app.tasks.document_processors._save.get_user_long_context_llm", "app.tasks.document_processors.markdown_processor.get_user_long_context_llm", @@ -134,6 +145,7 @@ def _patch_llm_bindings() -> None: _patch_llm_bindings() _fake_embeddings.install(_active_patches) _fake_native_google.install(_active_patches) +_fake_notion_module.install(_active_patches) # ---------------------------------------------------------------------------