open-notebook/tests/test_source_create_array_limits.py
Luis Novo 9857862b29
Some checks are pending
Development Build / summary (push) Blocked by required conditions
Development Build / extract-version (push) Waiting to run
Development Build / build-regular (push) Blocked by required conditions
Development Build / build-single (push) Blocked by required conditions
Tests / Backend Tests (push) Waiting to run
Tests / Frontend Tests (push) Waiting to run
fix: sort sources by title without tripping the SEARCH index, return 422 for invalid form data (#1042)
Two findings from v1.11 release testing:

- GET /api/sources?sort_by=title returned a 500. source.title carries a
  SEARCH (BM25) index (idx_source_title, migration 1) and SurrealDB's
  planner fails ORDER BY on such a column with 'No iterator has been
  found'. The query now sorts by a computed alias
  (string::lowercase(title OR '') AS title_sort), which sidesteps the
  index and makes the sort case-insensitive as a bonus.

- POST /api/sources with an over-limit notebooks/transformations array
  (or invalid JSON in either field) returned a raw 500.
  parse_source_form_data() builds SourceCreate manually, so pydantic's
  ValidationError never reached FastAPI's request-validation handler.
  Both cases now surface as a clean 422 with a descriptive message.

Verified against a live SurrealDB v2 instance; regression tests added
for the ORDER BY alias, all six sort fields, and the 422 paths.
2026-07-10 21:10:24 -03:00

103 lines
3.5 KiB
Python

"""
Tests for max_length on SourceCreate.notebooks/transformations
(api/models.py).
Both are iterated with a per-item DB lookup (Notebook.get()/
Transformation.get()) in api/routers/sources.py's create_source() - an
unbounded array let a caller amplify a single request into an arbitrarily
large number of sequential DB round trips.
"""
import pytest
from pydantic import ValidationError
from api.models import SourceCreate
def make_ids(n, prefix):
return [f"{prefix}:{i}" for i in range(n)]
class TestNotebooksMaxLength:
def test_accepts_up_to_50_notebooks(self):
request = SourceCreate(type="text", content="hi", notebooks=make_ids(50, "notebook"))
assert len(request.notebooks) == 50
def test_rejects_51_notebooks(self):
with pytest.raises(ValidationError):
SourceCreate(type="text", content="hi", notebooks=make_ids(51, "notebook"))
def test_none_notebooks_still_allowed(self):
# Pre-existing behavior (validate_notebook_fields): None normalizes
# to an empty list, unrelated to the max_length addition.
request = SourceCreate(type="text", content="hi", notebooks=None)
assert request.notebooks == []
def test_empty_list_still_allowed(self):
request = SourceCreate(type="text", content="hi", notebooks=[])
assert request.notebooks == []
class TestTransformationsMaxLength:
def test_accepts_up_to_50_transformations(self):
request = SourceCreate(
type="text", content="hi", transformations=make_ids(50, "transformation")
)
assert len(request.transformations) == 50
def test_rejects_51_transformations(self):
with pytest.raises(ValidationError):
SourceCreate(
type="text", content="hi", transformations=make_ids(51, "transformation")
)
def test_default_is_empty_list(self):
request = SourceCreate(type="text", content="hi")
assert request.transformations == []
class TestFormParsingReturns422:
"""The multipart form path builds SourceCreate manually in
parse_source_form_data(), so pydantic's ValidationError doesn't go
through FastAPI's request-validation handler — without an explicit
catch it surfaced as a 500. These hit the real endpoint and assert
the client gets a clean 422 instead (found in v1.11 release testing).
"""
@pytest.fixture
def client(self):
from fastapi.testclient import TestClient
from api.main import app
return TestClient(app)
def test_51_notebooks_via_form_returns_422(self, client):
import json as _json
response = client.post(
"/api/sources",
data={
"type": "text",
"content": "probe",
"notebooks": _json.dumps(make_ids(51, "notebook")),
},
)
assert response.status_code == 422
assert "Invalid source data" in response.json()["detail"]
def test_invalid_notebooks_json_returns_422(self, client):
response = client.post(
"/api/sources",
data={"type": "text", "content": "probe", "notebooks": "not-json["},
)
assert response.status_code == 422
assert "notebooks" in response.json()["detail"]
def test_invalid_transformations_json_returns_422(self, client):
response = client.post(
"/api/sources",
data={"type": "text", "content": "probe", "transformations": "]bad"},
)
assert response.status_code == 422
assert "transformations" in response.json()["detail"]