From 77cee8f6f3fff5c5d0d6fad65335eb10f6333025 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 21 Mar 2026 17:03:44 -0400 Subject: [PATCH] test: add Script Builder API tests Also fix bug in save_to_library: remove invalid 'language' kwarg passed to ScriptTemplate constructor (column doesn't exist on model). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/script_builder_service.py | 1 - backend/tests/test_script_builder.py | 310 ++++++++++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_script_builder.py diff --git a/backend/app/services/script_builder_service.py b/backend/app/services/script_builder_service.py index f07c2653..ae39fae7 100644 --- a/backend/app/services/script_builder_service.py +++ b/backend/app/services/script_builder_service.py @@ -351,7 +351,6 @@ async def save_to_library( slug=slug, description=description, script_body=session.latest_script, - language=session.language, parameters_schema={"parameters": []}, default_values={}, validation_rules={}, diff --git a/backend/tests/test_script_builder.py b/backend/tests/test_script_builder.py new file mode 100644 index 00000000..6d42e050 --- /dev/null +++ b/backend/tests/test_script_builder.py @@ -0,0 +1,310 @@ +# backend/tests/test_script_builder.py +"""Tests for the Script Builder API endpoints.""" +import pytest +import uuid +from unittest.mock import AsyncMock, patch, PropertyMock + +import sqlalchemy as sa + + +class TestScriptBuilderSessions: + """Test Script Builder session CRUD.""" + + @pytest.mark.asyncio + async def test_create_session(self, client, auth_headers): + """Creating a builder session returns a valid session.""" + resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["language"] == "powershell" + assert data["messages"] == [] + assert data["message_count"] == 0 + assert data["title"] is None + + @pytest.mark.asyncio + async def test_create_session_invalid_language(self, client, auth_headers): + """Invalid language is rejected.""" + resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "cobol"}, + headers=auth_headers, + ) + assert resp.status_code == 422 + + @pytest.mark.asyncio + async def test_create_session_bash(self, client, auth_headers): + """Bash language is accepted.""" + resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "bash"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + assert resp.json()["language"] == "bash" + + @pytest.mark.asyncio + async def test_list_sessions_empty(self, client, auth_headers): + """Listing sessions when none exist returns empty list.""" + resp = await client.get( + "/api/v1/scripts/builder/sessions", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + @pytest.mark.asyncio + async def test_list_sessions_returns_summaries(self, client, auth_headers): + """Listed sessions are lightweight (no messages field).""" + # Create a session first + await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + resp = await client.get( + "/api/v1/scripts/builder/sessions", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert "messages" not in data[0] + assert "language" in data[0] + assert "title" in data[0] + + @pytest.mark.asyncio + async def test_get_session_detail(self, client, auth_headers): + """Getting a session by ID returns full detail with messages.""" + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "python"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + resp = await client.get( + f"/api/v1/scripts/builder/sessions/{session_id}", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == session_id + assert "messages" in data + assert data["language"] == "python" + + @pytest.mark.asyncio + async def test_get_session_not_found(self, client, auth_headers): + """Getting a nonexistent session returns 404.""" + fake_id = str(uuid.uuid4()) + resp = await client.get( + f"/api/v1/scripts/builder/sessions/{fake_id}", + headers=auth_headers, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_delete_session(self, client, auth_headers): + """Deleting a session removes it.""" + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + resp = await client.delete( + f"/api/v1/scripts/builder/sessions/{session_id}", + headers=auth_headers, + ) + assert resp.status_code == 204 + + # Verify it's gone + resp = await client.get( + f"/api/v1/scripts/builder/sessions/{session_id}", + headers=auth_headers, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_delete_session_not_found(self, client, auth_headers): + """Deleting a nonexistent session returns 404.""" + fake_id = str(uuid.uuid4()) + resp = await client.delete( + f"/api/v1/scripts/builder/sessions/{fake_id}", + headers=auth_headers, + ) + assert resp.status_code == 404 + + +class TestScriptBuilderMessages: + """Test sending messages (requires AI mock).""" + + @pytest.fixture + def _enable_ai(self): + """Mock AI as enabled for tests without API key.""" + with patch.object( + type(__import__("app.core.config", fromlist=["settings"]).settings), + "ai_enabled", + new_callable=PropertyMock, + return_value=True, + ): + yield + + @pytest.fixture + def _mock_ai_response(self): + """Mock the AI provider to return a script response.""" + mock_response = ( + 'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.', + 100, # input_tokens + 200, # output_tokens + ) + with patch("app.services.script_builder_service.get_ai_provider") as mock: + provider = AsyncMock() + provider.generate_text = AsyncMock(return_value=mock_response) + mock.return_value = provider + yield + + @pytest.mark.asyncio + async def test_send_message(self, client, auth_headers, _enable_ai, _mock_ai_response): + """Sending a message returns AI response with script.""" + # Create session + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + # Send message + resp = await client.post( + f"/api/v1/scripts/builder/sessions/{session_id}/messages", + json={"content": "List all running processes"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["role"] == "assistant" + assert data["script"] is not None + assert "Get-Process" in data["script"] + assert data["script_filename"] == "Get-Processes.ps1" + assert data["line_count"] == 1 + + @pytest.mark.asyncio + async def test_send_message_updates_session(self, client, auth_headers, _enable_ai, _mock_ai_response): + """Sending a message updates session state.""" + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + await client.post( + f"/api/v1/scripts/builder/sessions/{session_id}/messages", + json={"content": "List processes"}, + headers=auth_headers, + ) + + # Check session was updated + resp = await client.get( + f"/api/v1/scripts/builder/sessions/{session_id}", + headers=auth_headers, + ) + data = resp.json() + assert data["message_count"] == 1 + assert data["latest_script"] is not None + assert len(data["messages"]) == 2 # user + assistant + assert data["title"] is not None + + +class TestScriptBuilderSaveToLibrary: + """Test saving generated scripts to the library.""" + + @pytest.fixture + def _enable_ai(self): + with patch.object( + type(__import__("app.core.config", fromlist=["settings"]).settings), + "ai_enabled", + new_callable=PropertyMock, + return_value=True, + ): + yield + + @pytest.fixture + def _mock_ai_response(self): + mock_response = ( + 'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.', + 100, 200, + ) + with patch("app.services.script_builder_service.get_ai_provider") as mock: + provider = AsyncMock() + provider.generate_text = AsyncMock(return_value=mock_response) + mock.return_value = provider + yield + + @pytest.fixture + async def _seed_ai_category(self, test_db): + """Seed the AI Generated category for save tests.""" + await test_db.execute( + sa.text(""" + INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at) + VALUES ( + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'AI Generated', 'ai-generated', 'Scripts from AI', 'sparkles', 100, true, NOW(), NOW() + ) + ON CONFLICT (slug) DO NOTHING + """) + ) + await test_db.commit() + + @pytest.mark.asyncio + async def test_save_to_library_no_script(self, client, auth_headers): + """Cannot save if no script has been generated.""" + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + resp = await client.post( + f"/api/v1/scripts/builder/sessions/{session_id}/save", + json={"name": "Test Script"}, + headers=auth_headers, + ) + assert resp.status_code == 400 + assert "No script" in resp.json()["detail"] + + @pytest.mark.asyncio + async def test_save_to_library_success( + self, client, auth_headers, _enable_ai, _mock_ai_response, _seed_ai_category + ): + """Saving a generated script creates a ScriptTemplate.""" + # Create session and generate a script + create_resp = await client.post( + "/api/v1/scripts/builder/sessions", + json={"language": "powershell"}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + await client.post( + f"/api/v1/scripts/builder/sessions/{session_id}/messages", + json={"content": "List processes"}, + headers=auth_headers, + ) + + # Save to library + resp = await client.post( + f"/api/v1/scripts/builder/sessions/{session_id}/save", + json={"name": "My Process Script", "share_with_team": False}, + headers=auth_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "My Process Script" + assert "ai-generated" in data["tags"]