253 lines
9.7 KiB
Python
253 lines
9.7 KiB
Python
"""Tests for the sources API endpoint."""
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from open_notebook.config import UPLOADS_FOLDER
|
|
from open_notebook.domain.notebook import Source
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Create test client after environment variables have been cleared by conftest."""
|
|
from api.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
class TestAsyncSourceAssetPersistence:
|
|
"""Tests for #627 - asset is persisted before async processing.
|
|
|
|
These tests hit the real create_source endpoint with mocked DB/command
|
|
calls, verifying that the Source saved to the database has the correct
|
|
asset set *before* async processing begins.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.CommandService.submit_command_job", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Source.add_to_notebook", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Notebook.get", new_callable=AsyncMock)
|
|
async def test_async_link_source_persists_url_asset(
|
|
self, mock_nb_get, mock_add_nb, mock_submit, client
|
|
):
|
|
"""POST /sources with type=link and async_processing=true persists Asset(url=...)."""
|
|
mock_nb_get.return_value = MagicMock()
|
|
mock_submit.return_value = "command:123"
|
|
|
|
saved_sources = []
|
|
|
|
async def capture_save(self_source):
|
|
saved_sources.append(self_source)
|
|
self_source.id = "source:fake"
|
|
self_source.command = None
|
|
|
|
with patch.object(Source, "save", autospec=True, side_effect=capture_save):
|
|
response = client.post(
|
|
"/api/sources",
|
|
data={
|
|
"type": "link",
|
|
"url": "https://example.com/article",
|
|
"notebooks": '["notebook:1"]',
|
|
"async_processing": "true",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert len(saved_sources) >= 1
|
|
|
|
source = saved_sources[0]
|
|
assert source.asset is not None
|
|
assert source.asset.url == "https://example.com/article"
|
|
assert source.asset.file_path is None
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.CommandService.submit_command_job", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Source.add_to_notebook", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Notebook.get", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.save_uploaded_file", new_callable=AsyncMock)
|
|
async def test_async_upload_source_persists_file_asset(
|
|
self, mock_upload, mock_nb_get, mock_add_nb, mock_submit, client
|
|
):
|
|
"""POST /sources with type=upload and async_processing=true persists Asset(file_path=...)."""
|
|
mock_nb_get.return_value = MagicMock()
|
|
mock_upload.return_value = os.path.join(os.path.abspath(UPLOADS_FOLDER), "video.mp4")
|
|
mock_submit.return_value = "command:123"
|
|
|
|
saved_sources = []
|
|
|
|
async def capture_save(self_source):
|
|
saved_sources.append(self_source)
|
|
self_source.id = "source:fake"
|
|
self_source.command = None
|
|
|
|
with patch.object(Source, "save", autospec=True, side_effect=capture_save):
|
|
response = client.post(
|
|
"/api/sources",
|
|
data={
|
|
"type": "upload",
|
|
"notebooks": '["notebook:1"]',
|
|
"async_processing": "true",
|
|
},
|
|
files={"file": ("video.mp4", b"fake content", "video/mp4")},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert len(saved_sources) >= 1
|
|
|
|
source = saved_sources[0]
|
|
assert source.asset is not None
|
|
assert source.asset.file_path == os.path.join(os.path.abspath(UPLOADS_FOLDER), "video.mp4")
|
|
assert source.asset.url is None
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.CommandService.submit_command_job", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Source.add_to_notebook", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Notebook.get", new_callable=AsyncMock)
|
|
async def test_async_text_source_has_no_asset(
|
|
self, mock_nb_get, mock_add_nb, mock_submit, client
|
|
):
|
|
"""POST /sources with type=text and async_processing=true has asset=None."""
|
|
mock_nb_get.return_value = MagicMock()
|
|
mock_submit.return_value = "command:123"
|
|
|
|
saved_sources = []
|
|
|
|
async def capture_save(self_source):
|
|
saved_sources.append(self_source)
|
|
self_source.id = "source:fake"
|
|
self_source.command = None
|
|
|
|
with patch.object(Source, "save", autospec=True, side_effect=capture_save):
|
|
response = client.post(
|
|
"/api/sources",
|
|
data={
|
|
"type": "text",
|
|
"content": "Some text content",
|
|
"notebooks": '["notebook:1"]',
|
|
"async_processing": "true",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert len(saved_sources) >= 1
|
|
|
|
source = saved_sources[0]
|
|
assert source.asset is None
|
|
|
|
|
|
class TestRetrySourceProcessing:
|
|
"""POST /sources/{id}/retry must find a source's notebooks via the reference
|
|
edge's in/out columns, not a non-existent `source` column (#861)."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.CommandService.submit_command_job", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Source.get", new_callable=AsyncMock)
|
|
async def test_retry_finds_notebooks_and_requeues(
|
|
self, mock_get, mock_repo_query, mock_submit, client
|
|
):
|
|
source = MagicMock()
|
|
source.id = "source:1"
|
|
source.command = None
|
|
source.title = "My source"
|
|
source.topics = []
|
|
source.full_text = None
|
|
source.asset = MagicMock(file_path=None, url="https://example.com/post")
|
|
source.save = AsyncMock()
|
|
source.get_embedded_chunks = AsyncMock(return_value=0)
|
|
mock_get.return_value = source
|
|
|
|
# The corrected query returns the linked notebook(s)
|
|
mock_repo_query.return_value = ["notebook:1"]
|
|
# submit_command_job returns str(RecordID), which already includes the
|
|
# "command:" table prefix.
|
|
mock_submit.return_value = "command:123"
|
|
|
|
response = client.post("/api/sources/source:1/retry")
|
|
|
|
assert response.status_code == 200
|
|
# Regression guard: must query the reference edge by its `in` column
|
|
called_query = mock_repo_query.await_args.args[0]
|
|
assert "WHERE in = $source_id" in called_query
|
|
assert "SELECT VALUE out FROM reference" in called_query
|
|
# Regression guard: command_id must not be double-prefixed
|
|
# (`command:command:…`), which previously raised a 500 on save.
|
|
assert "command:command" not in str(source.command)
|
|
assert str(source.command).count("command:") == 1
|
|
assert str(source.command).startswith("command:")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.sources.Source.get", new_callable=AsyncMock)
|
|
async def test_retry_400_only_when_truly_unlinked(
|
|
self, mock_get, mock_repo_query, client
|
|
):
|
|
source = MagicMock()
|
|
source.id = "source:1"
|
|
source.command = None
|
|
mock_get.return_value = source
|
|
mock_repo_query.return_value = [] # genuinely no notebooks
|
|
|
|
response = client.post("/api/sources/source:1/retry")
|
|
|
|
assert response.status_code == 400
|
|
assert "not associated with any notebooks" in response.json()["detail"]
|
|
|
|
|
|
class TestGetSourceNotFound:
|
|
"""GET /sources/{id} must return 404 (not 500) for a missing/deleted source.
|
|
`Source.get()` raises NotFoundError rather than returning None, so the handler
|
|
must map it to 404 instead of catching it in its generic `except`."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.Source.get", new_callable=AsyncMock)
|
|
async def test_get_missing_source_returns_404(self, mock_get, client):
|
|
from open_notebook.exceptions import NotFoundError
|
|
|
|
mock_get.side_effect = NotFoundError("source with id source:gone not found")
|
|
|
|
response = client.get("/api/sources/source:gone")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|
|
|
|
|
|
class TestTitleSortUsesAlias:
|
|
"""Regression for sort_by=title returning a 500 (v1.11 release testing).
|
|
|
|
source.title carries a SEARCH (BM25) index and SurrealDB's planner
|
|
fails ORDER BY on such a column with "No iterator has been found".
|
|
The router must therefore sort by the computed `title_sort` alias,
|
|
never by the raw indexed column.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.repo_query", new_callable=AsyncMock)
|
|
async def test_sort_by_title_orders_by_alias(self, mock_query, client):
|
|
mock_query.return_value = []
|
|
|
|
response = client.get("/api/sources?sort_by=title")
|
|
|
|
assert response.status_code == 200
|
|
query = mock_query.call_args[0][0]
|
|
assert "ORDER BY title_sort" in query
|
|
assert "AS title_sort" in query
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.sources.repo_query", new_callable=AsyncMock)
|
|
async def test_all_sort_fields_return_200(self, mock_query, client):
|
|
mock_query.return_value = []
|
|
for field in ["type", "title", "created", "updated", "insights_count", "embedded"]:
|
|
response = client.get(f"/api/sources?sort_by={field}")
|
|
assert response.status_code == 200, f"sort_by={field}"
|
|
|
|
def test_invalid_sort_field_returns_400(self, client):
|
|
response = client.get("/api/sources?sort_by=bogus")
|
|
assert response.status_code == 400
|