# backend/tests/test_script_builder.py """Tests for the Script Builder API endpoints.""" import pytest import uuid as uuid_mod 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_mod.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_mod.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"] class TestScriptBuilderMaxSessions: """Test the per-user session limit (MAX_SESSIONS_PER_USER = 5).""" @pytest.mark.asyncio async def test_max_sessions_limit(self, client, auth_headers): """Creating a 6th session should return 400.""" # Create 5 sessions (the maximum) for i in range(5): resp = await client.post( "/api/v1/scripts/builder/sessions", json={"language": "powershell"}, headers=auth_headers, ) assert resp.status_code == 201, f"Session {i+1} should succeed" # 6th session should be rejected resp = await client.post( "/api/v1/scripts/builder/sessions", json={"language": "powershell"}, headers=auth_headers, ) assert resp.status_code == 400 assert "Maximum" in resp.json()["detail"] assert "5" in resp.json()["detail"] @pytest.mark.asyncio async def test_max_sessions_after_delete_allows_new(self, client, auth_headers): """After deleting a session, creating a new one should succeed.""" session_ids = [] for _ in range(5): resp = await client.post( "/api/v1/scripts/builder/sessions", json={"language": "powershell"}, headers=auth_headers, ) session_ids.append(resp.json()["id"]) # Delete one await client.delete( f"/api/v1/scripts/builder/sessions/{session_ids[0]}", headers=auth_headers, ) # Now creating a new session should work resp = await client.post( "/api/v1/scripts/builder/sessions", json={"language": "bash"}, headers=auth_headers, ) assert resp.status_code == 201 class TestScriptBuilderMaxMessages: """Test the per-session message limit (MAX_MESSAGES_PER_SESSION = 30).""" @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\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.mark.asyncio async def test_max_messages_limit( self, client, auth_headers, test_db, _enable_ai, _mock_ai_response ): """Sending a message when session has 30 user messages should return 400.""" # Create a session create_resp = await client.post( "/api/v1/scripts/builder/sessions", json={"language": "powershell"}, headers=auth_headers, ) session_id = create_resp.json()["id"] # Insert 30 user message rows directly into the DB to reach the limit for i in range(30): await test_db.execute( sa.text(""" INSERT INTO script_builder_messages (id, session_id, role, content, created_at) VALUES (:id, :sid, 'user', :content, NOW()) """), { "id": str(uuid_mod.uuid4()), "sid": session_id, "content": f"message {i+1}", }, ) await test_db.commit() # Now sending a message should fail with 400 resp = await client.post( f"/api/v1/scripts/builder/sessions/{session_id}/messages", json={"content": "One more message"}, headers=auth_headers, ) assert resp.status_code == 400 assert "maximum" in resp.json()["detail"].lower() class TestScriptBuilderSlugCollision: """Test that saving a script with a colliding slug generates a unique slug.""" @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): 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_slug_collision_appends_suffix( self, client, auth_headers, test_db, _enable_ai, _mock_ai_response, _seed_ai_category ): """When a slug already exists, the saved template gets a UUID-suffixed slug.""" # Pre-create a template with slug "test-script" to cause collision user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] await test_db.execute( sa.text(""" INSERT INTO script_templates (id, category_id, created_by, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES (:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid, 'Test Script', 'test-script', 'echo hello', '{"parameters": []}', '{}', '{}', '["powershell"]', 'beginner', true, 1, 0, NOW(), NOW()) """), {"id": str(uuid_mod.uuid4()), "uid": user_id}, ) await test_db.commit() # Create a builder 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 with a name that would generate slug "test-script" resp = await client.post( f"/api/v1/scripts/builder/sessions/{session_id}/save", json={"name": "Test Script"}, headers=auth_headers, ) assert resp.status_code == 201 data = resp.json() # The slug should start with "test-script-" (base + UUID suffix) assert data["slug"].startswith("test-script-") assert data["slug"] != "test-script" class TestScriptTemplateFilters: """Test mine/shared query filters on GET /scripts/templates.""" @pytest.fixture async def _seed_category(self, test_db): """Seed a script category for template creation.""" await test_db.execute( sa.text(""" INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at) VALUES ( 'b0000000-0000-0000-0000-000000000001'::uuid, 'General', 'general', 'General scripts', 'code', 0, true, NOW(), NOW() ) ON CONFLICT (slug) DO NOTHING """) ) await test_db.commit() @pytest.fixture async def second_user_headers(self, client, test_db): """Create a second user on the same team as the test user and return their auth headers.""" # Register second user user_data = { "email": "second@example.com", "password": "TestPassword123!", "name": "Second User", } resp = await client.post("/api/v1/auth/register", json=user_data) assert resp.status_code in (200, 201) # Login to get headers login_resp = await client.post( "/api/v1/auth/login/json", json={"email": user_data["email"], "password": user_data["password"]}, ) assert login_resp.status_code == 200 token = login_resp.json()["access_token"] return {"Authorization": f"Bearer {token}"} @pytest.mark.asyncio async def test_mine_filter( self, client, auth_headers, test_db, test_user, second_user_headers, _seed_category ): """mine=true returns only templates created by the current user.""" user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers) second_user_id = second_resp.json()["id"] cat_id = "b0000000-0000-0000-0000-000000000001" # Create template owned by test user await test_db.execute( sa.text(""" INSERT INTO script_templates (id, category_id, created_by, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES (:id, :cat, :uid, NULL, 'My Script', 'my-script', 'echo mine', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id}, ) # Create template owned by second user (no team_id, so visible to all) await test_db.execute( sa.text(""" INSERT INTO script_templates (id, category_id, created_by, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES (:id, :cat, :uid, NULL, 'Other Script', 'other-script', 'echo other', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id}, ) await test_db.commit() # mine=true should only return the test user's template resp = await client.get( "/api/v1/scripts/templates?mine=true", headers=auth_headers, ) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["name"] == "My Script" @pytest.mark.asyncio async def test_shared_filter( self, client, auth_headers, test_db, test_user, _seed_category ): """shared=true returns only templates shared with the user's team.""" user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] cat_id = "b0000000-0000-0000-0000-000000000001" # Create a team and assign the test user to it team_id = str(uuid_mod.uuid4()) await test_db.execute( sa.text(""" INSERT INTO teams (id, name, created_at) VALUES (:tid, 'Test Team', NOW()) """), {"tid": team_id}, ) await test_db.execute( sa.text("UPDATE users SET team_id = :tid WHERE id = :uid"), {"tid": team_id, "uid": user_id}, ) await test_db.commit() # Template shared with user's team await test_db.execute( sa.text(""" INSERT INTO script_templates (id, category_id, created_by, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES (:id, :cat, :uid, :tid, 'Team Script', 'team-script', 'echo team', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id}, ) # Template NOT shared (no team_id) await test_db.execute( sa.text(""" INSERT INTO script_templates (id, category_id, created_by, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES (:id, :cat, :uid, NULL, 'Personal Script', 'personal-script', 'echo personal', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id}, ) await test_db.commit() # shared=true should only return the team-shared template resp = await client.get( "/api/v1/scripts/templates?shared=true", headers=auth_headers, ) assert resp.status_code == 200 data = resp.json() names = [t["name"] for t in data] assert "Team Script" in names assert "Personal Script" not in names