"""Direct service-layer tests for extracted ``cli/services/source_*`` modules.""" from __future__ import annotations import contextlib from collections.abc import AsyncIterator from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from notebooklm._app.source_content import ( SourceFulltextPlan, SourceGuidePlan, execute_source_fulltext, execute_source_guide, ) from notebooklm._app.source_wait import ( SourceWaitPlan, SourceWaitTimeout, execute_source_wait, ) from notebooklm.cli import _source_render, source_cmd from notebooklm.cli.services import source_mutations, source_research from notebooklm.cli.services.source_mutations import ( SourceDeletePlan, SourceMutationError, SourceRenamePlan, execute_source_delete, execute_source_rename, ) from notebooklm.cli.services.source_research import ( SourceAddResearchPlan, SourceAddResearchResult, execute_source_add_research, ) from notebooklm.types import ( MindMapResult, # noqa: F401 (re-exported for shared test helpers) ResearchSource, ResearchStart, ResearchStatus, ResearchTask, Source, SourceFulltext, SourceGuide, SourceTimeoutError, ) def _wait_task(spec: dict) -> ResearchTask: """Build a typed ``ResearchTask`` from a legacy wait/poll dict spec.""" raw_status = spec.get("status", "no_research") try: status = ResearchStatus(raw_status) except ValueError: status = ResearchStatus.FAILED raw_sources = spec.get("sources") or [] sources = tuple( ResearchSource.from_public_dict(s) for s in (raw_sources if isinstance(raw_sources, list) else []) if isinstance(s, dict) ) return ResearchTask( task_id=spec.get("task_id", ""), status=status, query=spec.get("query", "") if isinstance(spec.get("query"), str) else "", sources=sources, summary=spec.get("summary", ""), report=spec.get("report", "") if isinstance(spec.get("report"), str) else "", ) def _start(spec: dict) -> ResearchStart: """Build a typed ``ResearchStart`` from a legacy start dict spec.""" return ResearchStart( task_id=spec.get("task_id", ""), report_id=spec.get("report_id"), notebook_id=spec.get("notebook_id", ""), query=spec.get("query", ""), mode=spec.get("mode", "fast"), ) @pytest.mark.asyncio async def test_source_delete_json_without_yes_uses_structured_confirmation_error() -> None: client = SimpleNamespace( sources=SimpleNamespace( list=AsyncMock(return_value=[Source(id="src_abcdef", title="Paper")]), delete=AsyncMock(), ) ) plan = SourceDeletePlan( notebook_id="nb_1", source_id="src_abc", yes=False, json_output=True, ) with pytest.raises(SourceMutationError) as exc_info: await execute_source_delete( client, plan, confirmer=lambda message: pytest.fail(f"unexpected confirmation: {message}"), ) assert exc_info.value.message == "Pass --yes to confirm destructive operation in --json mode" assert exc_info.value.code == "CONFIRM_REQUIRED" assert exc_info.value.extra == { "action": "delete", "source_id": "src_abcdef", "notebook_id": "nb_1", } assert exc_info.value.status_message == "[dim]Matched: src_abcdef... (Paper)[/dim]" client.sources.delete.assert_not_called() @pytest.mark.asyncio async def test_source_rename_returns_payload(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( source_mutations, "resolve_source_id", AsyncMock(return_value="src_full"), ) client = SimpleNamespace( sources=SimpleNamespace(rename=AsyncMock(return_value=Source(id="src_full", title="New"))) ) result = await execute_source_rename( client, SourceRenamePlan( notebook_id="nb_1", source_id="src", new_title="New", json_output=True, ), ) client.sources.rename.assert_awaited_once_with("nb_1", "src_full", "New") # The typed result carries fields only; the --json envelope is built by the # CLI render layer (§11). Assert the relocated builder is byte-identical. assert result.source.id == "src_full" assert result.source.title == "New" assert result.notebook_id == "nb_1" assert _source_render._source_rename_payload(result) == { "action": "rename", "source_id": "src_full", "notebook_id": "nb_1", "title": "New", "status": "renamed", } @pytest.mark.asyncio async def test_source_fulltext_service_returns_fetched_content() -> None: client = SimpleNamespace( sources=SimpleNamespace( get_fulltext=AsyncMock( return_value=SourceFulltext( source_id="src_1", title="Paper", content="full content", char_count=12, ) ) ) ) result = await execute_source_fulltext( client, SourceFulltextPlan( notebook_id="nb_1", source_id="src_1", output_format="text", ), ) assert result.fulltext.source_id == "src_1" assert result.fulltext.title == "Paper" assert result.fulltext.content == "full content" assert result.fulltext.char_count == 12 @pytest.mark.asyncio async def test_source_guide_service_normalizes_untrusted_backend_payload() -> None: # The typed ``SourceGuide`` return carries the (still possibly untrusted) # backend values; the service strips/normalizes the keywords and blanks a # non-str summary. client = SimpleNamespace( sources=SimpleNamespace( get_guide=AsyncMock( return_value=SourceGuide( summary=42, # type: ignore[arg-type] keywords=[" alpha ", 7, "", " ", None, "beta"], # type: ignore[list-item] ) ) ) ) result = await execute_source_guide( client, SourceGuidePlan(notebook_id="nb_1", source_id="src_1"), ) assert result.source_id == "src_1" assert result.summary == "" assert result.keywords == ("alpha", "beta") @pytest.mark.asyncio async def test_source_guide_service_treats_empty_guide_as_empty() -> None: client = SimpleNamespace( sources=SimpleNamespace(get_guide=AsyncMock(return_value=SourceGuide())) ) result = await execute_source_guide( client, SourceGuidePlan(notebook_id="nb_1", source_id="src_1"), ) assert result.source_id == "src_1" assert result.is_empty @pytest.mark.asyncio async def test_source_add_research_waits_with_started_task_id_and_imports( monkeypatch: pytest.MonkeyPatch, ) -> None: imported = SimpleNamespace(imported=["src_1"], cited_selection=None, sources=[]) import_research_sources = AsyncMock(return_value=imported) monkeypatch.setattr(source_research, "import_research_sources", import_research_sources) client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock( return_value=_wait_task( { "status": "completed", "task_id": "task_123", "sources": [{"title": "Result"}], "report": "Report", } ) ), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="deep", import_all=True, cited_only=True, no_wait=False, timeout=30, ), ) assert result.outcome == "completed" assert result.poll_task_id == "task_123" assert result.sources == [{"url": "", "title": "Result", "result_type": 1}] assert result.report == "Report" assert result.import_result is imported client.research.start.assert_awaited_once_with("nb_1", "topic", "web", "deep") client.research.wait_for_completion.assert_awaited_once_with( "nb_1", task_id="task_123", timeout=30.0, initial_interval=5.0, ) import_research_sources.assert_awaited_once_with( client, "nb_1", "task_123", [{"url": "", "title": "Result", "result_type": 1}], report="Report", cited_only=True, max_elapsed=30, ) @pytest.mark.asyncio async def test_source_add_research_json_import_stays_silent( monkeypatch: pytest.MonkeyPatch, ) -> None: imported = SimpleNamespace(imported=["src_1"], cited_selection=None, sources=[]) import_research_sources = AsyncMock(return_value=imported) monkeypatch.setattr(source_research, "import_research_sources", import_research_sources) client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock( return_value=_wait_task( { "status": "completed", "task_id": "task_123", "sources": [{"title": "Result"}], "report": "Report", } ) ), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=True, cited_only=True, no_wait=False, timeout=30, json_output=True, ), ) assert result.outcome == "completed" import_research_sources.assert_awaited_once_with( client, "nb_1", "task_123", [{"url": "", "title": "Result", "result_type": 1}], report="Report", cited_only=True, max_elapsed=30, json_output=True, ) def test_render_add_research_started_no_wait_json_payload( monkeypatch: pytest.MonkeyPatch, ) -> None: payloads: list[dict[str, object]] = [] monkeypatch.setattr(_source_render, "json_output_response", payloads.append) _source_render._render_add_research_result( SourceAddResearchResult( outcome="started_no_wait", plan=SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="deep", import_all=False, cited_only=False, no_wait=True, timeout=30, json_output=True, ), start_task_id="task_123", poll_task_id="report_456", ), json_output=True, ) assert payloads == [ { "status": "started", "task_id": "task_123", "poll_task_id": "report_456", } ] def test_render_add_research_completed_json_payload( monkeypatch: pytest.MonkeyPatch, ) -> None: payloads: list[dict[str, object]] = [] monkeypatch.setattr(_source_render, "json_output_response", payloads.append) import_result = SimpleNamespace( imported=[{"id": "src_1", "title": "Result"}], cited_selection=SimpleNamespace(used_fallback=False), sources=[{"title": "Result"}], ) _source_render._render_add_research_result( SourceAddResearchResult( outcome="completed", plan=SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=True, cited_only=True, no_wait=False, timeout=30, json_output=True, ), start_task_id="task_123", poll_task_id="task_123", sources=[{"title": "Result"}], report="Report", import_result=import_result, ), json_output=True, ) assert payloads == [ { "status": "completed", "task_id": "task_123", "sources_found": 1, "sources": [{"title": "Result"}], "report": "Report", "cited_only": True, "cited_sources_selected": 1, "cited_only_fallback": False, "imported": 1, "imported_sources": [{"id": "src_1", "title": "Result"}], } ] def test_render_add_research_completed_text_keeps_task_ids( monkeypatch: pytest.MonkeyPatch, ) -> None: printed: list[object] = [] monkeypatch.setattr( source_cmd.console, "print", lambda message="", *_, **__: printed.append(message) ) monkeypatch.setattr(_source_render, "display_research_sources", lambda sources: None) monkeypatch.setattr(_source_render, "display_report", lambda report, json_hint=False: None) _source_render._render_add_research_result( SourceAddResearchResult( outcome="completed", plan=SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="deep", import_all=False, cited_only=False, no_wait=False, timeout=30, ), start_task_id="task_123", poll_task_id="report_456", sources=[{"title": "Result"}], report="Report", ), json_output=False, ) assert "[dim]Task ID: task_123[/dim]" in printed assert "[dim]Poll ID: report_456[/dim]" in printed @pytest.mark.asyncio async def test_source_add_research_failed_returns_terminal_outcome() -> None: # ``wait_for_completion`` now returns a typed ``ResearchTask``; a ``FAILED`` # status maps to the ``failed`` terminal outcome. (The ``timeout`` outcome # is reached only via the ``TimeoutError`` branch, not a returned status — # the typed return can never carry a "timeout" status string.) client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock( return_value=_wait_task({"status": "failed", "task_id": "task_123"}) ), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="deep", import_all=False, cited_only=False, no_wait=False, timeout=30, ), ) assert result.outcome == "failed" assert result.start_task_id == "task_123" assert result.poll_task_id == "task_123" @pytest.mark.asyncio async def test_source_add_research_start_failed_returns_outcome() -> None: """``research.start`` returning falsy maps to ``outcome='start_failed'``.""" client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=None), wait_for_completion=AsyncMock(), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=False, cited_only=False, no_wait=False, timeout=30, ), ) assert result.outcome == "start_failed" assert result.start_task_id is None client.research.wait_for_completion.assert_not_awaited() @pytest.mark.asyncio async def test_source_add_research_timeout_error_maps_to_timeout_outcome() -> None: """``wait_for_completion`` raising :class:`TimeoutError` maps to ``timeout``.""" client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock(side_effect=TimeoutError("budget exhausted")), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=False, cited_only=False, no_wait=False, timeout=30, ), ) assert result.outcome == "timeout" assert result.poll_task_id == "task_123" @pytest.mark.asyncio async def test_source_add_research_unknown_status_returns_unknown_outcome() -> None: # ``wait_for_completion`` should only ever return a terminal status, but the # service keeps a defensive fallback for any non-terminal status it sees # (here ``IN_PROGRESS``) -> ``unknown_status``. client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock( return_value=_wait_task({"status": "in_progress", "task_id": "task_123"}) ), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="deep", import_all=False, cited_only=False, no_wait=False, timeout=30, ), ) assert result.outcome == "unknown_status" assert result.status == "in_progress" @pytest.mark.asyncio async def test_source_add_research_no_wait_returns_early_outcome() -> None: """``--no-wait`` skips the wait loop and returns ``started_no_wait``.""" client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock(), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=False, cited_only=False, no_wait=True, timeout=30, ), ) assert result.outcome == "started_no_wait" assert result.start_task_id == "task_123" client.research.wait_for_completion.assert_not_awaited() @pytest.mark.asyncio async def test_source_add_research_delegates_timeout_budget_to_research_api() -> None: client = SimpleNamespace( research=SimpleNamespace( start=AsyncMock(return_value=_start({"task_id": "task_123"})), wait_for_completion=AsyncMock( return_value=_wait_task( { "status": "completed", "task_id": "task_123", "sources": [], "report": "", } ) ), ) ) result = await execute_source_add_research( client, SourceAddResearchPlan( notebook_id="nb_1", query="topic", search_source="web", mode="fast", import_all=False, cited_only=False, no_wait=False, timeout=6, ), ) assert result.outcome == "completed" client.research.wait_for_completion.assert_awaited_once_with( "nb_1", task_id="task_123", timeout=6.0, initial_interval=5.0, ) @pytest.mark.asyncio async def test_source_wait_timeout_returns_typed_outcome() -> None: @contextlib.asynccontextmanager async def fake_status_with_elapsed(*args: object, **kwargs: object) -> AsyncIterator[None]: yield timeout_exc = SourceTimeoutError("src_1", 10.0, 2) client = SimpleNamespace( sources=SimpleNamespace(wait_until_ready=AsyncMock(side_effect=timeout_exc)) ) outcome = await execute_source_wait( client, SourceWaitPlan( notebook_id="nb_1", source_id="src_1", timeout=10.0, interval=0.5, ), wait_context=lambda: fake_status_with_elapsed(), ) # Service now returns a typed outcome; presentation + exit code are the # caller's responsibility (covered by tests/unit/cli/test_source.py). assert isinstance(outcome, SourceWaitTimeout) assert outcome.error is timeout_exc client.sources.wait_until_ready.assert_awaited_once_with( "nb_1", "src_1", timeout=10.0, initial_interval=0.5, )