"""Unit tests for GET /api/v1/scripts/count endpoint. Verifies the count endpoint returns the same scope as ``list_scripts(parent_path="")``: workspace + active scripts whose ``StorageObjects.relative_path`` lives under the user's subtree. This avoids under/over-reporting on the dashboard — the count is the size of the set list_scripts would return if it weren't lazy. """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from backend.scripts import count_scripts def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), workspace=SimpleNamespace(workspace_id=workspace_id), role=SimpleNamespace(role_code="admin"), is_system_admin=False, ) def _compile(stmt) -> str: from sqlalchemy.dialects import mysql as mysql_dialect return str( stmt.compile( dialect=mysql_dialect.dialect(), compile_kwargs={"literal_binds": True}, ) ) async def test_count_scripts_returns_scalar_int() -> None: captured = [] mock_session = MagicMock() mock_session.scalar = AsyncMock( side_effect=lambda stmt: (captured.append(stmt), 7)[1] ) result = await count_scripts(context=_ctx(), session=mock_session) assert result["data"] == {"total": 7} assert result["meta"] == {} assert result["request_id"] == "test" # Exactly one COUNT(*) query issued. assert len(captured) == 1 stmt = captured[0] sql = _compile(stmt).lower() # JOIN to StorageObjects so orphaned scripts (no joinable row) are # excluded — matches list_scripts INNER JOIN behaviour. assert "inner join storage_objects" in sql # Scope: workspace_id + active status + user subtree. assert "scripts.workspace_id" in sql assert "scripts.status" in sql assert "workspace/u001/%" in sql async def test_count_scripts_handles_null_result() -> None: """MySQL COUNT(*) on empty result returns 0, not NULL — but defensively coerce NULL to 0 to keep the response shape consistent.""" mock_session = MagicMock() mock_session.scalar = AsyncMock(return_value=None) result = await count_scripts(context=_ctx(), session=mock_session) assert result["data"] == {"total": 0} async def test_count_scripts_uses_user_specific_subtree() -> None: """Different users in the same workspace must see different totals — each user's count is bounded by their own ``workspace/{user_id}/`` subtree, NOT the whole workspace.""" captured = [] mock_session = MagicMock() mock_session.scalar = AsyncMock( side_effect=lambda stmt: (captured.append(stmt), 3)[1] ) await count_scripts(context=_ctx(user_id="alice"), session=mock_session) sql_alice = _compile(captured[-1]) await count_scripts(context=_ctx(user_id="bob"), session=mock_session) sql_bob = _compile(captured[-1]) assert "workspace/alice/%" in sql_alice assert "workspace/alice/%" not in sql_bob assert "workspace/bob/%" in sql_bob async def test_count_scripts_route_declared_before_script_id_route() -> None: """Static check: the `/api/v1/scripts/count` route MUST be declared in scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's declaration-order matching will interpret `count` as a script_id.""" from backend.scripts import count_scripts, get_script assert callable(count_scripts) assert callable(get_script)