spaceforge 0.1.0.dev0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. spaceforge/__init__.py +12 -4
  2. spaceforge/__main__.py +3 -3
  3. spaceforge/_version.py +0 -1
  4. spaceforge/_version_scm.py +34 -0
  5. spaceforge/cls.py +24 -14
  6. spaceforge/conftest.py +89 -0
  7. spaceforge/generator.py +129 -56
  8. spaceforge/plugin.py +199 -22
  9. spaceforge/runner.py +3 -15
  10. spaceforge/schema.json +45 -22
  11. spaceforge/templates/binary_install.sh.j2 +24 -0
  12. spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +24 -0
  13. spaceforge/{generator_test.py → test_generator.py} +265 -53
  14. spaceforge/test_generator_binaries.py +194 -0
  15. spaceforge/test_generator_core.py +180 -0
  16. spaceforge/test_generator_hooks.py +90 -0
  17. spaceforge/test_generator_parameters.py +59 -0
  18. spaceforge/test_plugin.py +357 -0
  19. spaceforge/test_plugin_file_operations.py +118 -0
  20. spaceforge/test_plugin_hooks.py +100 -0
  21. spaceforge/test_plugin_inheritance.py +102 -0
  22. spaceforge/{runner_test.py → test_runner.py} +5 -68
  23. spaceforge/test_runner_cli.py +69 -0
  24. spaceforge/test_runner_core.py +124 -0
  25. spaceforge/test_runner_execution.py +169 -0
  26. spaceforge-1.0.1.dist-info/METADATA +606 -0
  27. spaceforge-1.0.1.dist-info/RECORD +33 -0
  28. spaceforge/plugin_test.py +0 -621
  29. spaceforge-0.1.0.dev0.dist-info/METADATA +0 -163
  30. spaceforge-0.1.0.dev0.dist-info/RECORD +0 -19
  31. /spaceforge/{cls_test.py → test_cls.py} +0 -0
  32. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/WHEEL +0 -0
  33. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/entry_points.txt +0 -0
  34. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/licenses/LICENSE +0 -0
  35. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,180 @@
1
+ """Tests for PluginGenerator core functionality."""
2
+
3
+ import os
4
+ from unittest.mock import Mock, patch
5
+
6
+ import pytest
7
+
8
+ from spaceforge.generator import PluginGenerator
9
+ from spaceforge.plugin import SpaceforgePlugin
10
+
11
+
12
+ class TestPluginGeneratorInitialization:
13
+ """Test PluginGenerator initialization and configuration."""
14
+
15
+ def test_should_initialize_with_custom_paths(self) -> None:
16
+ """Should accept and store custom plugin and output paths."""
17
+ # Arrange & Act
18
+ generator = PluginGenerator("custom_plugin.py", "custom_output.yaml")
19
+
20
+ # Assert
21
+ assert generator.plugin_path == "custom_plugin.py"
22
+ assert generator.output_path == "custom_output.yaml"
23
+ assert generator.plugin_class is None
24
+ assert generator.plugin_instance is None
25
+ assert generator.plugin_working_directory is None
26
+
27
+ def test_should_use_defaults_when_no_paths_provided(self) -> None:
28
+ """Should use default paths when none specified."""
29
+ # Arrange & Act
30
+ generator = PluginGenerator()
31
+
32
+ # Assert
33
+ assert generator.plugin_path == "plugin.py"
34
+ assert generator.output_path == "plugin.yaml"
35
+
36
+
37
+ class TestPluginGeneratorLoading:
38
+ """Test plugin file loading functionality."""
39
+
40
+ def test_should_raise_file_not_found_when_plugin_file_missing(self) -> None:
41
+ """Should raise FileNotFoundError when plugin file doesn't exist."""
42
+ # Arrange
43
+ generator = PluginGenerator("nonexistent.py")
44
+
45
+ # Act & Assert
46
+ with pytest.raises(FileNotFoundError, match="Plugin file not found"):
47
+ generator.load_plugin()
48
+
49
+ def test_should_raise_exception_when_plugin_file_has_syntax_errors(
50
+ self, temp_dir: str
51
+ ) -> None:
52
+ """Should raise exception when plugin file has invalid Python syntax."""
53
+ # Arrange
54
+ invalid_path = os.path.join(temp_dir, "invalid.py")
55
+ with open(invalid_path, "w") as f:
56
+ f.write("invalid python syntax }")
57
+
58
+ generator = PluginGenerator(invalid_path)
59
+
60
+ # Act & Assert
61
+ with pytest.raises(Exception): # Could be syntax error or import error
62
+ generator.load_plugin()
63
+
64
+ def test_should_raise_value_error_when_no_spaceforge_plugin_found(
65
+ self, temp_dir: str
66
+ ) -> None:
67
+ """Should raise ValueError when file has no SpaceforgePlugin subclass."""
68
+ # Arrange
69
+ no_plugin_path = os.path.join(temp_dir, "no_plugin.py")
70
+ with open(no_plugin_path, "w") as f:
71
+ f.write(
72
+ """
73
+ class NotAPlugin:
74
+ pass
75
+ """
76
+ )
77
+
78
+ generator = PluginGenerator(no_plugin_path)
79
+
80
+ # Act & Assert
81
+ with pytest.raises(ValueError, match="No SpaceforgePlugin subclass found"):
82
+ generator.load_plugin()
83
+
84
+ def test_should_load_plugin_successfully_when_valid_file_provided(
85
+ self, test_plugin_file: str
86
+ ) -> None:
87
+ """Should successfully load plugin from valid file."""
88
+ # Arrange
89
+ generator = PluginGenerator(test_plugin_file)
90
+
91
+ # Act
92
+ generator.load_plugin()
93
+
94
+ # Assert
95
+ assert generator.plugin_class is not None
96
+ assert generator.plugin_instance is not None
97
+ assert generator.plugin_class.__name__ == "TestPlugin"
98
+ assert generator.plugin_working_directory == "/mnt/workspace/plugins/test"
99
+
100
+ @patch("spaceforge.generator.importlib.util.spec_from_file_location")
101
+ def test_should_raise_import_error_when_spec_is_none(
102
+ self, mock_spec: Mock, test_plugin_file: str
103
+ ) -> None:
104
+ """Should raise ImportError when importlib spec creation fails."""
105
+ # Arrange
106
+ mock_spec.return_value = None
107
+ generator = PluginGenerator(test_plugin_file)
108
+
109
+ # Act & Assert
110
+ with pytest.raises(ImportError, match="Could not load plugin"):
111
+ generator.load_plugin()
112
+
113
+
114
+ class TestPluginGeneratorMetadata:
115
+ """Test metadata extraction functionality."""
116
+
117
+ def test_should_extract_complete_metadata_when_all_attributes_present(self) -> None:
118
+ """Should extract all metadata when plugin has complete attributes."""
119
+
120
+ # Arrange
121
+ class CompletePlugin(SpaceforgePlugin):
122
+ """Complete test plugin."""
123
+
124
+ __plugin_name__ = "complete_test"
125
+ __version__ = "2.0.0"
126
+ __author__ = "Test Author"
127
+
128
+ generator = PluginGenerator()
129
+ generator.plugin_class = CompletePlugin
130
+
131
+ # Act
132
+ metadata = generator.get_plugin_metadata()
133
+
134
+ # Assert
135
+ assert metadata["name_prefix"] == "complete_test"
136
+ assert metadata["version"] == "2.0.0"
137
+ assert metadata["author"] == "Test Author"
138
+ assert metadata["description"] == "Complete test plugin."
139
+
140
+ def test_should_use_defaults_when_metadata_attributes_missing(self) -> None:
141
+ """Should use default values when plugin metadata attributes are missing."""
142
+
143
+ # Arrange
144
+ class MinimalPlugin(SpaceforgePlugin):
145
+ pass
146
+
147
+ generator = PluginGenerator()
148
+ generator.plugin_class = MinimalPlugin
149
+
150
+ # Act
151
+ metadata = generator.get_plugin_metadata()
152
+
153
+ # Assert
154
+ assert metadata["name_prefix"] == "SpaceforgePlugin" # inherited
155
+ assert metadata["version"] == "1.0.0" # inherited from base
156
+ assert metadata["author"] == "Spacelift Team" # inherited from base
157
+ assert "MinimalPlugin" in metadata["description"]
158
+
159
+ def test_should_generate_name_from_class_name_when_plugin_name_missing(
160
+ self,
161
+ ) -> None:
162
+ """Should derive name from class name when __plugin_name__ not set."""
163
+
164
+ # Arrange
165
+ class MinimalPlugin: # Don't inherit from SpaceforgePlugin
166
+ __name__ = "MinimalPlugin"
167
+
168
+ generator = PluginGenerator()
169
+ generator.plugin_class = MinimalPlugin # type: ignore[assignment]
170
+
171
+ # Act
172
+ metadata = generator.get_plugin_metadata()
173
+
174
+ # Assert
175
+ assert (
176
+ metadata["name_prefix"] == "minimal"
177
+ ) # class name lowercased with 'plugin' removed
178
+ assert metadata["version"] == "1.0.0" # default
179
+ assert metadata["author"] == "Unknown" # default
180
+ assert "MinimalPlugin" in metadata["description"]
@@ -0,0 +1,90 @@
1
+ """Tests for PluginGenerator hook detection."""
2
+
3
+ import os
4
+
5
+ from pytest import MonkeyPatch
6
+
7
+ from spaceforge.generator import PluginGenerator
8
+ from spaceforge.plugin import SpaceforgePlugin
9
+
10
+
11
+ class TestPluginGeneratorHooks:
12
+ """Test hook method detection functionality."""
13
+
14
+ def test_should_detect_overridden_hook_methods(self) -> None:
15
+ """Should identify hook methods that have been overridden in plugin."""
16
+
17
+ # Arrange
18
+ class HookedPlugin(SpaceforgePlugin):
19
+ def after_plan(self) -> None:
20
+ pass
21
+
22
+ def before_apply(self) -> None:
23
+ pass
24
+
25
+ generator = PluginGenerator()
26
+ generator.plugin_class = HookedPlugin
27
+
28
+ # Act
29
+ hooks = generator.get_available_hooks()
30
+
31
+ # Assert
32
+ assert "after_plan" in hooks
33
+ assert "before_apply" in hooks
34
+ assert len(hooks) == 2
35
+
36
+ def test_should_return_empty_list_when_no_hooks_overridden(self) -> None:
37
+ """Should return empty list when plugin has no overridden hook methods."""
38
+
39
+ # Arrange
40
+ class NoHooksPlugin(SpaceforgePlugin):
41
+ pass
42
+
43
+ generator = PluginGenerator()
44
+ generator.plugin_class = NoHooksPlugin
45
+
46
+ # Act
47
+ hooks = generator.get_available_hooks()
48
+
49
+ # Assert
50
+ assert hooks == []
51
+
52
+ def test_contexts_should_not_have_duplicates_pip_install_in_hooks(
53
+ self, temp_dir: str, monkeypatch: MonkeyPatch
54
+ ) -> None:
55
+ """Should not have duplicate hook methods in the list."""
56
+
57
+ # Arrange
58
+ plugin_content = """
59
+ from spaceforge import SpaceforgePlugin
60
+ class DuplicateHooksPlugin(SpaceforgePlugin):
61
+ def before_init(self) -> None:
62
+ pass
63
+ """
64
+
65
+ requirements_content = """
66
+ requests
67
+ """
68
+
69
+ plugin_path = os.path.join(temp_dir, "plugin.py")
70
+ with open(plugin_path, "w") as f:
71
+ f.write(plugin_content)
72
+ with open(os.path.join(temp_dir, "requirements.txt"), "w") as f:
73
+ f.write(requirements_content)
74
+
75
+ # Change to temporary directory (automatically restored after test)
76
+ monkeypatch.chdir(temp_dir)
77
+
78
+ generator = PluginGenerator(plugin_path)
79
+ generator.load_plugin()
80
+
81
+ # Act
82
+ contexts = generator.get_plugin_contexts()
83
+ hooks = contexts[0].hooks
84
+ assert hooks is not None
85
+
86
+ # Assert
87
+ processed_hooks = []
88
+ for hook in hooks["before_init"]:
89
+ assert hook not in processed_hooks
90
+ processed_hooks.append(hook)
@@ -0,0 +1,59 @@
1
+ """Tests for PluginGenerator parameter handling."""
2
+
3
+ from spaceforge.cls import Parameter
4
+ from spaceforge.generator import PluginGenerator
5
+ from spaceforge.plugin import SpaceforgePlugin
6
+
7
+
8
+ class TestPluginGeneratorParameters:
9
+ """Test parameter extraction and processing."""
10
+
11
+ def test_should_extract_parameters_when_defined(self) -> None:
12
+ """Should extract and return parameter list when plugin defines them."""
13
+
14
+ # Arrange
15
+ class ParameterizedPlugin(SpaceforgePlugin):
16
+ __parameters__ = [
17
+ Parameter(
18
+ name="api_key",
19
+ description="API key for authentication",
20
+ required=True,
21
+ sensitive=True,
22
+ ),
23
+ Parameter(
24
+ name="endpoint",
25
+ description="API endpoint URL",
26
+ required=False,
27
+ default="https://api.example.com",
28
+ ),
29
+ ]
30
+
31
+ generator = PluginGenerator()
32
+ generator.plugin_class = ParameterizedPlugin
33
+
34
+ # Act
35
+ parameters = generator.get_plugin_parameters()
36
+
37
+ # Assert
38
+ assert parameters is not None
39
+ assert len(parameters) == 2
40
+ assert parameters[0].name == "api_key"
41
+ assert parameters[0].sensitive is True
42
+ assert parameters[1].name == "endpoint"
43
+ assert parameters[1].default == "https://api.example.com"
44
+
45
+ def test_should_return_none_when_no_parameters_defined(self) -> None:
46
+ """Should return None when plugin has no parameters."""
47
+
48
+ # Arrange
49
+ class NoParamsPlugin(SpaceforgePlugin):
50
+ pass
51
+
52
+ generator = PluginGenerator()
53
+ generator.plugin_class = NoParamsPlugin
54
+
55
+ # Act
56
+ parameters = generator.get_plugin_parameters()
57
+
58
+ # Assert
59
+ assert parameters is None
@@ -0,0 +1,357 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ from typing import Dict
6
+ from unittest.mock import Mock, patch
7
+
8
+ import pytest
9
+
10
+ from spaceforge.plugin import SpaceforgePlugin
11
+
12
+
13
+ class TestSpaceforgePluginInitialization:
14
+ """Test SpaceforgePlugin initialization and configuration."""
15
+
16
+ def test_should_initialize_with_defaults_when_no_environment_set(self) -> None:
17
+ """Should set default values when no environment variables are provided."""
18
+ # Arrange & Act
19
+ with patch.dict(os.environ, {}, clear=True):
20
+ plugin = SpaceforgePlugin()
21
+
22
+ # Assert
23
+ assert plugin._api_token is False
24
+ assert plugin._api_endpoint is False
25
+ assert plugin._api_enabled is False
26
+ assert plugin._workspace_root == os.getcwd()
27
+ assert isinstance(plugin.logger, logging.Logger)
28
+
29
+ def test_should_enable_api_when_valid_credentials_provided(
30
+ self, mock_env: Dict[str, str]
31
+ ) -> None:
32
+ """Should enable API access when both token and endpoint are provided."""
33
+ # Arrange & Act
34
+ with patch.dict(os.environ, mock_env, clear=True):
35
+ plugin = SpaceforgePlugin()
36
+
37
+ # Assert
38
+ assert plugin._api_token == "test_token"
39
+ assert plugin._api_endpoint == "https://test.spacelift.io"
40
+ assert plugin._api_enabled is True
41
+ assert plugin._workspace_root == os.getcwd()
42
+
43
+ def test_should_normalize_domain_with_trailing_slash(self) -> None:
44
+ """Should remove trailing slash from domain URL."""
45
+ # Arrange
46
+ test_env = {
47
+ "SPACELIFT_API_TOKEN": "test_token",
48
+ "TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io/",
49
+ }
50
+
51
+ # Act
52
+ with patch.dict(os.environ, test_env, clear=True):
53
+ plugin = SpaceforgePlugin()
54
+
55
+ # Assert
56
+ assert plugin._api_endpoint == "https://test.spacelift.io"
57
+ assert plugin._api_enabled is True
58
+
59
+ def test_should_disable_api_when_domain_has_no_https_prefix(self) -> None:
60
+ """Should disable API when domain doesn't use HTTPS."""
61
+ # Arrange
62
+ test_env = {
63
+ "SPACELIFT_API_TOKEN": "test_token",
64
+ "TF_VAR_spacelift_graphql_endpoint": "test.spacelift.io",
65
+ }
66
+
67
+ # Act
68
+ with patch.dict(os.environ, test_env, clear=True):
69
+ plugin = SpaceforgePlugin()
70
+
71
+ # Assert
72
+ assert plugin._api_endpoint == "test.spacelift.io"
73
+ assert plugin._api_enabled is False
74
+
75
+ def test_should_disable_api_when_only_token_provided(self) -> None:
76
+ """Should disable API when only token is provided without domain."""
77
+ # Arrange & Act
78
+ with patch.dict(os.environ, {"SPACELIFT_API_TOKEN": "test_token"}, clear=True):
79
+ plugin = SpaceforgePlugin()
80
+
81
+ # Assert
82
+ assert plugin._api_enabled is False
83
+
84
+ def test_should_disable_api_when_only_domain_provided(self) -> None:
85
+ """Should disable API when only domain is provided without token."""
86
+ # Arrange & Act
87
+ with patch.dict(
88
+ os.environ,
89
+ {"TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io"},
90
+ clear=True,
91
+ ):
92
+ plugin = SpaceforgePlugin()
93
+
94
+ # Assert
95
+ assert plugin._api_enabled is False
96
+
97
+
98
+ class TestSpaceforgePluginLogging:
99
+ """Test logging configuration and functionality."""
100
+
101
+ def test_should_configure_logger_with_correct_name_and_level(self) -> None:
102
+ """Should set up logger with proper name and level."""
103
+ # Arrange & Act
104
+ with patch.dict(os.environ, {}, clear=True):
105
+ plugin = SpaceforgePlugin()
106
+
107
+ # Assert
108
+ assert plugin.logger.name == "spaceforge.SpaceforgePlugin"
109
+ assert len(plugin.logger.handlers) >= 1
110
+ assert plugin.logger.getEffectiveLevel() <= logging.INFO
111
+
112
+ def test_should_enable_debug_logging_when_debug_env_set(self) -> None:
113
+ """Should set DEBUG level when SPACELIFT_DEBUG environment variable is true."""
114
+ # Arrange
115
+ test_env = {"SPACELIFT_DEBUG": "true"}
116
+
117
+ # Act
118
+ with patch.dict(os.environ, test_env, clear=True):
119
+ plugin = SpaceforgePlugin()
120
+
121
+ # Assert
122
+ assert plugin.logger.level == logging.DEBUG
123
+
124
+ def test_should_include_run_id_in_log_format(self) -> None:
125
+ """Should include run ID in log message format when available."""
126
+ # Arrange
127
+ test_env = {"TF_VAR_spacelift_run_id": "run-123"}
128
+
129
+ # Act
130
+ with patch.dict(os.environ, test_env, clear=True):
131
+ plugin = SpaceforgePlugin()
132
+ formatter = plugin.logger.handlers[0].formatter
133
+
134
+ record = logging.LogRecord(
135
+ name="test",
136
+ level=logging.INFO,
137
+ pathname="",
138
+ lineno=0,
139
+ msg="test message",
140
+ args=(),
141
+ exc_info=None,
142
+ )
143
+ record.levelname = "INFO"
144
+
145
+ # Assert
146
+ assert formatter is not None
147
+ formatted = formatter.format(record)
148
+ assert "[run-123]" in formatted or "[local]" in formatted
149
+
150
+ def test_logger_color_formatting(self) -> None:
151
+ """Test color formatting for different log levels."""
152
+ plugin = SpaceforgePlugin()
153
+ formatter = plugin.logger.handlers[0].formatter
154
+ assert formatter is not None
155
+
156
+ # Test different log levels
157
+ levels_to_test = [
158
+ (logging.INFO, "INFO"),
159
+ (logging.DEBUG, "DEBUG"),
160
+ (logging.WARNING, "WARNING"),
161
+ (logging.ERROR, "ERROR"),
162
+ ]
163
+
164
+ for level, level_name in levels_to_test:
165
+ record = logging.LogRecord(
166
+ name="test",
167
+ level=level,
168
+ pathname="",
169
+ lineno=0,
170
+ msg="test message",
171
+ args=(),
172
+ exc_info=None,
173
+ )
174
+ record.levelname = level_name
175
+ formatted = formatter.format(record)
176
+
177
+ # Should contain color codes and plugin name
178
+ assert "\033[" in formatted # ANSI color codes
179
+ assert "(SpaceforgePlugin)" in formatted
180
+ assert "test message" in formatted
181
+
182
+
183
+ # Hook tests moved to test_plugin_hooks.py
184
+
185
+
186
+ class TestSpaceforgePluginCLI:
187
+ """Test command-line interface execution functionality."""
188
+
189
+ def test_should_execute_cli_command_and_log_output_on_success(self) -> None:
190
+ """Should run CLI command and log output when execution succeeds."""
191
+ # Arrange
192
+ plugin = SpaceforgePlugin()
193
+ mock_process = Mock()
194
+ mock_process.communicate.return_value = (b"success output\n", None)
195
+ mock_process.returncode = 0
196
+
197
+ # Act
198
+ with patch("subprocess.Popen") as mock_popen:
199
+ with patch.object(plugin.logger, "info") as mock_info:
200
+ mock_popen.return_value = mock_process
201
+ plugin.run_cli("echo", "test")
202
+
203
+ # Assert
204
+ mock_popen.assert_called_once_with(
205
+ ("echo", "test"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
206
+ )
207
+ mock_info.assert_called_with("success output")
208
+
209
+ def test_should_log_error_when_cli_command_fails(self) -> None:
210
+ """Should log error details when CLI command returns non-zero exit code."""
211
+ # Arrange
212
+ plugin = SpaceforgePlugin()
213
+ mock_process = Mock()
214
+ mock_process.communicate.return_value = (None, b"error output\n")
215
+ mock_process.returncode = 1
216
+
217
+ # Act
218
+ with patch("subprocess.Popen") as mock_popen:
219
+ with patch.object(plugin.logger, "error") as mock_error:
220
+ mock_popen.return_value = mock_process
221
+ plugin.run_cli("false")
222
+
223
+ # Assert
224
+ mock_error.assert_any_call("Command failed with return code 1")
225
+ mock_error.assert_any_call("error output")
226
+
227
+ def test_run_cli_with_multiple_args(self) -> None:
228
+ """Test CLI command with multiple arguments."""
229
+ plugin = SpaceforgePlugin()
230
+
231
+ with patch("subprocess.Popen") as mock_popen:
232
+ mock_process = Mock()
233
+ mock_process.communicate.return_value = (b"", None)
234
+ mock_process.returncode = 0
235
+ mock_popen.return_value = mock_process
236
+
237
+ with patch.object(plugin.logger, "debug") as mock_debug:
238
+ plugin.run_cli("git", "status", "--porcelain")
239
+
240
+ mock_popen.assert_called_once_with(
241
+ ("git", "status", "--porcelain"),
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.STDOUT,
244
+ )
245
+ mock_debug.assert_called_with("Running CLI command: git status --porcelain")
246
+
247
+
248
+ class TestSpaceforgePluginAPI:
249
+ """Test GraphQL API interaction functionality."""
250
+
251
+ def test_should_exit_with_error_when_api_disabled(self) -> None:
252
+ """Should exit with error message when API is not enabled."""
253
+ # Arrange
254
+ plugin = SpaceforgePlugin()
255
+ plugin._api_enabled = False
256
+
257
+ # Act & Assert
258
+ with patch.object(plugin.logger, "error") as mock_error:
259
+ with pytest.raises(SystemExit):
260
+ plugin.query_api("query { test }")
261
+
262
+ mock_error.assert_called_with(
263
+ 'API is not enabled, please export "SPACELIFT_API_TOKEN" and "TF_VAR_spacelift_graphql_endpoint".'
264
+ )
265
+
266
+ def test_should_make_successful_api_request_with_correct_format(
267
+ self, mock_api_response: Mock
268
+ ) -> None:
269
+ """Should execute GraphQL query with proper authentication and format."""
270
+ # Arrange
271
+ plugin = SpaceforgePlugin()
272
+ plugin._api_enabled = True
273
+ plugin._api_token = "test_token"
274
+ plugin._api_endpoint = "https://test.spacelift.io"
275
+
276
+ expected_data = {"data": {"test": "result"}}
277
+ mock_api_response.read.return_value = json.dumps(expected_data).encode("utf-8")
278
+
279
+ # Act
280
+ with patch("urllib.request.urlopen") as mock_urlopen:
281
+ with patch("urllib.request.Request") as mock_request:
282
+ mock_urlopen.return_value.__enter__ = Mock(
283
+ return_value=mock_api_response
284
+ )
285
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
286
+
287
+ result = plugin.query_api("query { test }")
288
+
289
+ # Assert
290
+ mock_request.assert_called_once()
291
+ call_args = mock_request.call_args[0]
292
+ assert call_args[0] == "https://test.spacelift.io"
293
+
294
+ request_data = json.loads(call_args[1].decode("utf-8"))
295
+ assert request_data["query"] == "query { test }"
296
+
297
+ headers = mock_request.call_args[0][2]
298
+ assert headers["Content-Type"] == "application/json"
299
+ assert headers["Authorization"] == "Bearer test_token"
300
+
301
+ assert result == expected_data
302
+
303
+ def test_query_api_with_variables(self) -> None:
304
+ """Test API query with variables."""
305
+ plugin = SpaceforgePlugin()
306
+ plugin._api_enabled = True
307
+ plugin._api_token = "test_token"
308
+ plugin._api_endpoint = "https://test.spacelift.io"
309
+
310
+ mock_response_data = {"data": {"test": "result"}}
311
+ mock_response = Mock()
312
+ mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
313
+
314
+ variables = {"stackId": "test-stack"}
315
+
316
+ with patch("urllib.request.urlopen") as mock_urlopen:
317
+ with patch("urllib.request.Request") as mock_request:
318
+ mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
319
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
320
+
321
+ plugin.query_api(
322
+ "query ($stackId: ID!) { stack(id: $stackId) { name } }", variables
323
+ )
324
+
325
+ # Verify request data includes variables
326
+ request_data = json.loads(mock_request.call_args[0][1].decode("utf-8"))
327
+ assert request_data["variables"] == variables
328
+
329
+ def test_query_api_with_errors(self) -> None:
330
+ """Test API query that returns errors."""
331
+ plugin = SpaceforgePlugin()
332
+ plugin._api_enabled = True
333
+ plugin._api_token = "test_token"
334
+ plugin._api_endpoint = "https://test.spacelift.io"
335
+
336
+ mock_response_data = {"errors": [{"message": "Test error"}]}
337
+ mock_response = Mock()
338
+ mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
339
+
340
+ with patch("urllib.request.urlopen") as mock_urlopen:
341
+ with patch.object(plugin.logger, "error") as mock_error:
342
+ mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
343
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
344
+
345
+ result = plugin.query_api("query { test }")
346
+
347
+ mock_error.assert_called_with("Error: [{'message': 'Test error'}]")
348
+ assert result == mock_response_data
349
+
350
+
351
+ # File operation tests moved to test_plugin_file_operations.py
352
+
353
+
354
+ # Inheritance tests moved to test_plugin_inheritance.py
355
+
356
+
357
+ # Edge case tests moved to test_plugin_inheritance.py