feat(scripts): listScripts parent_path filter + lazy load by directory
Backend — backend/src/backend/scripts.py
- list_scripts accepts optional parent_path Query (default "").
- Extracts _build_list_scripts_descendant_prefix helper for the
"direct children of parent_path" prefix.
- WHERE clause now adds:
StorageObjects.relative_path LIKE '<prefix>/%'
AND NOT LIKE '<prefix>/%/%'
so deeper descendants and prefix-siblings (foo/bar vs foo/bar2)
are excluded. Empty parent_path filters to root-level only —
this is the symmetric, intent-aligned behavior the lazy-load
frontend relies on.
- EXPLAIN confirms idx_scripts_workspace drives the scripts table;
storage_objects PK lookup applies the LIKE filter per row.
Frontend — frontend/app/services/api.ts + scriptWorkspaceStore.ts
- listScripts accepts optional parentPath; built URL preserves
the new filter param.
- Store gains loadedScriptPaths / loadingScriptPaths Sets and a
loadScripts(parentPath) action: idempotent, in-flight dedupe,
dedup-by-id when merging into the flat scripts array so existing
find() callers keep working.
- load() now uses listScripts("") instead of bulk fetch — root
only on first paint.
- toggleExpanded() triggers loadScripts(parentPath) in parallel
with loadChildren(parentPath) so folder expansion loads both
sub-directories and direct-child scripts.
Tests — backend/tests/test_list_scripts_parent_path.py
- 7 unit tests: prefix construction, normalization, traversal
rejection, and SQL compilation contract (LIKE prefix + NOT LIKE
prefix + scripts.status filter).
Verified:
- pytest backend/tests: 50 passed (43 existing + 7 new)
- pnpm typecheck: clean
- alembic: no schema changes (migration-less feature)
- EXPLAIN with real workspace: idx_scripts_workspace → PK lookup
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Unit tests for the parent_path filter clause on list_scripts.
|
||||
|
||||
Verifies the WHERE clause built by the endpoint encodes the intended
|
||||
"direct children of parent_path" semantics: ``relative_path LIKE 'prefix/%'``
|
||||
and ``NOT LIKE 'prefix/%/%'``. The actual ORM roundtrip is covered by the
|
||||
existing integration tests; here we only care that the filter contract is
|
||||
intact, so an AsyncMock session is enough.
|
||||
|
||||
Mirrors the pattern in test_scripts.py (unit-level, no live DB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.scripts import (
|
||||
_build_list_scripts_descendant_prefix,
|
||||
normalize_user_path,
|
||||
)
|
||||
from sqlalchemy.dialects import mysql as mysql_dialect
|
||||
|
||||
|
||||
def _ctx(user_id: str = "U001") -> SimpleNamespace:
|
||||
"""Stand-in for RequestContext — only ``user.user_id`` and ``workspace_id`` are read."""
|
||||
return SimpleNamespace(
|
||||
request_id="test",
|
||||
user=SimpleNamespace(user_id=user_id),
|
||||
workspace=SimpleNamespace(workspace_id="W001"),
|
||||
role=SimpleNamespace(role_code="admin"),
|
||||
is_system_admin=False,
|
||||
)
|
||||
|
||||
|
||||
def test_descendant_prefix_root() -> None:
|
||||
"""Empty parent_path → descendant prefix is the scoped root + '/'."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "")
|
||||
assert prefix == "workspace/alice/"
|
||||
|
||||
|
||||
def test_descendant_prefix_subdir() -> None:
|
||||
"""Non-empty parent_path → appended under the scoped root."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/bar")
|
||||
assert prefix == "workspace/alice/foo/bar/"
|
||||
|
||||
|
||||
def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None:
|
||||
"""Leading/trailing slashes on parent_path must be stripped."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/")
|
||||
assert prefix == "workspace/alice/foo/bar/"
|
||||
|
||||
|
||||
def test_descendant_prefix_rejects_traversal() -> None:
|
||||
"""``..`` segments must raise (matches normalize_user_path contract)."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar")
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_normalize_user_path_strips() -> None:
|
||||
assert normalize_user_path("") == ""
|
||||
assert normalize_user_path("/a/b/") == "a/b"
|
||||
assert normalize_user_path("a\\b") == "a/b"
|
||||
|
||||
|
||||
async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
|
||||
"""The WHERE clause must include both LIKE prefix and NOT LIKE '%/%' filters
|
||||
so deeper descendants and prefix-siblings (foo/bar vs foo/bar2) are excluded."""
|
||||
from backend.scripts import list_scripts
|
||||
|
||||
captured_sql: list[str] = []
|
||||
|
||||
class _MockResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=lambda stmt: (
|
||||
captured_sql.append(
|
||||
str(
|
||||
stmt.compile(
|
||||
dialect=mysql_dialect.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
)
|
||||
or _MockResult()
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="foo/bar", context=_ctx("alice"), session=mock_session)
|
||||
|
||||
assert len(captured_sql) == 1
|
||||
sql = captured_sql[0].lower()
|
||||
# Direct-child LIKE prefix
|
||||
assert "like 'workspace/alice/foo/bar/%%'" in sql
|
||||
# NOT-LIKE deeper
|
||||
assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql
|
||||
# active scripts only (existing contract preserved)
|
||||
assert "scripts.status" in sql or "scripts.status = 'active'" in sql or "scripts.status = :status" in sql
|
||||
|
||||
|
||||
async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None:
|
||||
"""parent_path='' produces root-scoped LIKE prefix only, not full scan."""
|
||||
from backend.scripts import list_scripts
|
||||
|
||||
captured_sql: list[str] = []
|
||||
|
||||
class _MockResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=lambda stmt: (
|
||||
captured_sql.append(
|
||||
str(
|
||||
stmt.compile(
|
||||
dialect=mysql_dialect.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
)
|
||||
or _MockResult()
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session)
|
||||
|
||||
sql = captured_sql[0].lower()
|
||||
assert "like 'workspace/alice/%%'" in sql
|
||||
assert "not like 'workspace/alice/%%/%%'" in sql
|
||||
Reference in New Issue
Block a user