""" Tests for src/web_interface/errors.py — the structured error type behind every API error response (default suggestions, the JSON shape, and exception conversion). Pure logic; no Flask context needed. Regression coverage for one fixed bug: suggested_fixes used `or`, so a caller passing [] to mean "no suggestions" silently got the default list. """ import pytest from src.web_interface.errors import ErrorCode, WebInterfaceError class TestDefaultSuggestions: def test_mapped_code_gets_specific_suggestions(self): fixes = WebInterfaceError(ErrorCode.CONFIG_SAVE_FAILED, "msg").suggested_fixes assert "Check available disk space" in fixes def test_unmapped_code_gets_generic_fallback(self): # PLUGIN_UPDATE_FAILED has no entry in suggestions_map. fixes = WebInterfaceError(ErrorCode.PLUGIN_UPDATE_FAILED, "msg").suggested_fixes assert fixes == ["Review error details and try again"] def test_explicit_suggestions_win(self): error = WebInterfaceError( ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=["Do the thing"]) assert error.suggested_fixes == ["Do the thing"] def test_explicit_empty_list_is_respected(self): # Regression: `suggested_fixes or default` treated [] as "unset", # so a caller could not express "I have no suggestions". error = WebInterfaceError( ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=[]) assert error.suggested_fixes == [] def test_none_still_gets_defaults(self): error = WebInterfaceError( ErrorCode.CONFIG_SAVE_FAILED, "msg", suggested_fixes=None) assert len(error.suggested_fixes) > 0 class TestToDict: def test_base_keys_always_present(self): result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict() assert result["status"] == "error" assert result["error_code"] == "SYSTEM_ERROR" assert result["message"] == "boom" # No error_category: nothing in the UI, tests or plugins ever read it. assert set(result) == {"status", "error_code", "message", "suggested_fixes"} def test_details_included_when_set(self): result = WebInterfaceError( ErrorCode.SYSTEM_ERROR, "boom", details="disk full").to_dict() assert result["details"] == "disk full" def test_details_omitted_when_absent(self): assert "details" not in WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict() def test_context_included_when_non_empty(self): result = WebInterfaceError( ErrorCode.SYSTEM_ERROR, "boom", context={"path": "/tmp/x"}).to_dict() assert result["context"] == {"path": "/tmp/x"} def test_empty_context_is_omitted(self): # Pinned as intentional, not a bug: __init__ normalizes context to # {}, and an empty context carries no information, so it is left out # rather than padding every error body with "context": {}. result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom", context={}).to_dict() assert "context" not in result def test_empty_suggestions_omitted(self): result = WebInterfaceError( ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=[]).to_dict() assert "suggested_fixes" not in result def test_is_json_serializable(self): import json error = WebInterfaceError( ErrorCode.NETWORK_ERROR, "boom", details="timeout", context={"url": "http://x"}) assert json.loads(json.dumps(error.to_dict()))["error_code"] == "NETWORK_ERROR" class TestFromException: def test_the_given_code_is_reported(self): error = WebInterfaceError.from_exception( ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND) assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND def test_message_is_the_safe_one_not_the_exception_text(self): # The raw exception text is not echoed into `message`; that field is # a fixed, user-facing string per code. error = WebInterfaceError.from_exception(ValueError("secret-ish detail"), ErrorCode.UNKNOWN_ERROR) assert error.message == "An unexpected error occurred" assert "secret-ish" not in error.message def test_exception_type_recorded_in_context(self): error = WebInterfaceError.from_exception(ValueError("boom"), ErrorCode.UNKNOWN_ERROR) assert error.context["exception_type"] == "ValueError" def test_caller_context_is_preserved_alongside_type(self): error = WebInterfaceError.from_exception( ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"plugin_id": "clock"}) assert error.context["plugin_id"] == "clock" assert error.context["exception_type"] == "ValueError" def test_caller_supplied_exception_type_is_overwritten(self): error = WebInterfaceError.from_exception( ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"exception_type": "Fake"}) assert error.context["exception_type"] == "ValueError" def test_original_error_retained(self): exc = ValueError("boom") assert WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).original_error is exc def test_every_code_has_a_safe_message(self): for code in ErrorCode: assert WebInterfaceError._safe_message(code) class TestExceptionDetails: def test_context_dict_is_flattened(self): exc = ValueError("boom") exc.context = {"config_path": "/etc/x.json", "line": 4} details = WebInterfaceError._get_exception_details(exc) assert "config_path: /etc/x.json" in details assert "line: 4" in details assert "; " in details def test_exception_type_key_excluded(self): exc = ValueError("boom") exc.context = {"exception_type": "ValueError", "path": "/tmp/x"} details = WebInterfaceError._get_exception_details(exc) assert "exception_type" not in details assert details == "path: /tmp/x" def test_context_with_only_exception_type_gives_none(self): exc = ValueError("boom") exc.context = {"exception_type": "ValueError"} assert WebInterfaceError._get_exception_details(exc) is None def test_no_context_attribute_gives_none(self): assert WebInterfaceError._get_exception_details(ValueError("boom")) is None def test_non_dict_context_gives_none(self): exc = ValueError("boom") exc.context = "not a dict" assert WebInterfaceError._get_exception_details(exc) is None def test_empty_context_gives_none(self): exc = ValueError("boom") exc.context = {} assert WebInterfaceError._get_exception_details(exc) is None def test_details_flow_into_from_exception(self): exc = ValueError("boom") exc.context = {"config_path": "/etc/x.json"} assert "config_path" in WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).details