@@ -243,6 +243,27 @@ def test_load_metadata_uses_yaml_safe_load_when_available(self):
243243 self .assertEqual (metadata , {"name" : "from-yaml" , "version" : "1.0.0" })
244244 fake_yaml .safe_load .assert_called_once ()
245245
246+ def test_load_metadata_rejects_non_mapping_yaml_root (self ):
247+ module = load_validator_module ()
248+
249+ with tempfile .NamedTemporaryFile ("w" , suffix = ".yml" , delete = False ) as handle :
250+ handle .write ("- item\n " )
251+ metadata_path = Path (handle .name )
252+
253+ fake_yaml = mock .Mock ()
254+ fake_yaml .safe_load .return_value = ["item" ]
255+ fake_yaml .YAMLError = ValueError
256+
257+ try :
258+ with mock .patch .object (module , "yaml" , fake_yaml ):
259+ with self .assertRaisesRegex (
260+ module .MetadataLoadError ,
261+ "metadata.yaml must contain a mapping at the top level" ,
262+ ):
263+ module .load_metadata (metadata_path )
264+ finally :
265+ os .remove (metadata_path )
266+
246267 def test_load_metadata_uses_simple_parser_when_yaml_unavailable (self ):
247268 module = load_validator_module ()
248269
@@ -347,6 +368,117 @@ def test_validate_selected_plugins_emits_progress_and_result_lines(self):
347368 print_mock .assert_any_call ("[2/2] FAIL plugin-b [metadata] invalid metadata.yaml" , flush = True )
348369
349370
371+ class ValidatePluginTests (unittest .TestCase ):
372+ def setUp (self ):
373+ self .module = load_validator_module ()
374+ self .plugin_key = "demo-plugin"
375+ self .plugin_data = {"repo" : "https://github.com/example/demo-plugin" }
376+ self .astrbot_path = Path ("/tmp/AstrBot" )
377+ self .script_path = Path ("/tmp/run.py" )
378+ self .work_dir = Path ("/tmp/work" )
379+
380+ def call_validate_plugin (self , plugin_data = None ):
381+ return self .module .validate_plugin (
382+ plugin = self .plugin_key ,
383+ plugin_data = self .plugin_data if plugin_data is None else plugin_data ,
384+ astrbot_path = self .astrbot_path ,
385+ script_path = self .script_path ,
386+ work_dir = self .work_dir ,
387+ clone_timeout = 30 ,
388+ load_timeout = 60 ,
389+ )
390+
391+ def test_missing_repo_field_sets_repo_url_stage (self ):
392+ result = self .call_validate_plugin (plugin_data = {})
393+
394+ self .assertFalse (result ["ok" ])
395+ self .assertEqual (result ["stage" ], "repo_url" )
396+ self .assertEqual (result ["message" ], "missing repo field" )
397+
398+ def test_invalid_repo_url_sets_repo_url_stage (self ):
399+ with mock .patch .object (self .module , "normalize_repo_url" , side_effect = ValueError ("invalid repo URL" )):
400+ result = self .call_validate_plugin ()
401+
402+ self .assertFalse (result ["ok" ])
403+ self .assertEqual (result ["stage" ], "repo_url" )
404+ self .assertEqual (result ["message" ], "invalid repo URL" )
405+
406+ def test_clone_called_process_error_uses_stderr_or_stdout (self ):
407+ error = subprocess .CalledProcessError (
408+ returncode = 1 ,
409+ cmd = ["git" , "clone" ],
410+ output = "clone stdout" ,
411+ stderr = "clone stderr" ,
412+ )
413+
414+ with mock .patch .object (self .module , "clone_plugin_repo" , side_effect = error ):
415+ result = self .call_validate_plugin ()
416+
417+ self .assertFalse (result ["ok" ])
418+ self .assertEqual (result ["stage" ], "clone" )
419+ self .assertIn ("clone stderr" , result ["message" ])
420+
421+ def test_clone_timeout_uses_process_output_details (self ):
422+ timeout = subprocess .TimeoutExpired (cmd = ["git" , "clone" ], timeout = 30 , output = "slow" , stderr = "warning" )
423+
424+ with mock .patch .object (self .module , "clone_plugin_repo" , side_effect = timeout ):
425+ with mock .patch .object (
426+ self .module ,
427+ "build_process_output_details" ,
428+ return_value = {"stdout" : "slow" , "stderr" : "warning" },
429+ ) as details_mock :
430+ result = self .call_validate_plugin ()
431+
432+ self .assertFalse (result ["ok" ])
433+ self .assertEqual (result ["stage" ], "clone_timeout" )
434+ self .assertEqual (result ["details" ], {"stdout" : "slow" , "stderr" : "warning" })
435+ details_mock .assert_called_once_with (stdout = "slow" , stderr = "warning" )
436+
437+ def test_precheck_failure_is_mapped_into_result (self ):
438+ with mock .patch .object (self .module , "clone_plugin_repo" ):
439+ with mock .patch .object (
440+ self .module ,
441+ "precheck_plugin_directory" ,
442+ return_value = {"ok" : False , "stage" : "metadata" , "message" : "invalid metadata" , "details" : "line 3" },
443+ ):
444+ result = self .call_validate_plugin ()
445+
446+ self .assertFalse (result ["ok" ])
447+ self .assertEqual (result ["stage" ], "metadata" )
448+ self .assertEqual (result ["message" ], "invalid metadata" )
449+ self .assertEqual (result ["details" ], "line 3" )
450+
451+ def test_successful_clone_and_precheck_invokes_worker_and_parses_output (self ):
452+ completed = subprocess .CompletedProcess (
453+ args = ["python3" , "run.py" ],
454+ returncode = 0 ,
455+ stdout = '{"ok": true}' ,
456+ stderr = "" ,
457+ )
458+ parsed_output = {"ok" : True , "stage" : "load" , "message" : "plugin loaded successfully" }
459+
460+ with mock .patch .object (
461+ self .module ,
462+ "precheck_plugin_directory" ,
463+ return_value = {"ok" : True , "plugin_dir_name" : "demo_plugin" , "message" : "ok" , "stage" : "precheck" },
464+ ) as precheck_mock :
465+ with mock .patch .object (self .module , "clone_plugin_repo" ):
466+ with mock .patch .object (subprocess , "run" , return_value = completed ) as run_mock :
467+ with mock .patch .object (self .module , "parse_worker_output" , return_value = parsed_output ) as parse_mock :
468+ result = self .call_validate_plugin ()
469+
470+ self .assertEqual (result , parsed_output )
471+ precheck_mock .assert_called_once ()
472+ run_mock .assert_called_once ()
473+ parse_mock .assert_called_once_with (
474+ plugin = self .plugin_key ,
475+ repo = self .plugin_data ["repo" ],
476+ normalized_repo_url = self .plugin_data ["repo" ],
477+ completed = completed ,
478+ plugin_dir_name = "demo_plugin" ,
479+ )
480+
481+
350482class MetadataValidationTests (unittest .TestCase ):
351483 def test_reports_missing_required_metadata_fields (self ):
352484 module = load_validator_module ()
0 commit comments