""" Deep behavioral tests for all news exception classes. Tests status codes, error codes, to_dict, details, and inheritance. """ import pytest from local_deep_research.news.exceptions import ( DatabaseAccessException, InvalidLimitException, InvalidParameterException, NewsAPIException, NewsFeatureDisabledException, NewsFeedGenerationException, NotImplementedException, ResearchProcessingException, SchedulerNotificationException, SubscriptionCreationException, SubscriptionDeletionException, SubscriptionNotFoundException, SubscriptionUpdateException, ) # --- Base exception --- class TestNewsAPIException: """Tests for base NewsAPIException.""" def test_message_stored(self): e = NewsAPIException("something broke") assert e.message == "something broke" def test_default_status_code_500(self): e = NewsAPIException("err") assert e.status_code == 500 def test_custom_status_code(self): e = NewsAPIException("err", status_code=400) assert e.status_code == 400 def test_error_code_defaults_to_class_name(self): e = NewsAPIException("err") assert e.error_code == "NewsAPIException" def test_custom_error_code(self): e = NewsAPIException("err", error_code="CUSTOM") assert e.error_code == "CUSTOM" def test_details_defaults_empty(self): e = NewsAPIException("err") assert e.details == {} def test_custom_details(self): e = NewsAPIException("err", details={"key": "val"}) assert e.details == {"key": "val"} def test_to_dict_structure(self): e = NewsAPIException("broken", status_code=400, error_code="BAD") d = e.to_dict() assert d["error"] == "broken" assert d["error_code"] == "BAD" assert d["status_code"] == 400 def test_to_dict_excludes_empty_details(self): e = NewsAPIException("err") d = e.to_dict() assert "details" not in d def test_to_dict_includes_details_when_present(self): e = NewsAPIException("err", details={"info": "extra"}) d = e.to_dict() assert d["details"] == {"info": "extra"} def test_is_exception(self): e = NewsAPIException("err") assert isinstance(e, Exception) def test_str_representation(self): e = NewsAPIException("message here") assert str(e) == "message here" class TestToDictMessageSanitization: """`to_dict()` runs `message` through the central client sanitizer. This is a boundary backstop: the primary defence is curated messages at the `news/api.py` raise sites. These tests lock in that the backstop scrubs credential-bearing text while leaving legitimate messages — the class of content a denylist would have false-positived on — untouched. """ def test_benign_message_passes_through_unchanged(self): # An egress-policy rejection reason is legitimate, user-facing text. # It must reach the client verbatim (the central sanitizer only # redacts credential patterns, so it does not fire here). msg = ( "Failed to create subscription: search engine 'brave' is not " "permitted under the current egress policy" ) assert NewsAPIException(msg).to_dict()["error"] == msg def test_credential_keyword_in_content_not_scrubbed(self): # Plain prose containing "password" is not a credential and must # survive — the denylist approach wrongly nuked this. msg = "how do I reset my password?" assert NewsAPIException(msg).to_dict()["error"] == msg def test_bearer_token_redacted(self): e = NewsAPIException( "auth failed: Authorization: Bearer sk-abc123DEF456ghi789" ) error = e.to_dict()["error"] # The load-bearing assertion is that the secret is gone. The redaction # sentinel varies (`[REDACTED]` / `[REDACTED_KEY]`), so match on the # order-independent substring rather than an exact bracket form. assert "sk-abc123DEF456ghi789" not in error assert "REDACTED" in error def test_api_key_query_param_redacted(self): e = NewsAPIException( "call failed: ?api_key=AIzaSyD-EXAMPLE_KEY_1234567890abcd" ) error = e.to_dict()["error"] assert "AIzaSyD-EXAMPLE_KEY_1234567890abcd" not in error assert "REDACTED" in error def test_long_message_truncated_to_client_cap(self): # sanitize_error_for_client caps the client-facing message at 200 chars. # Locks in that to_dict uses the truncating client sanitizer (not the # no-cap variant) so an over-long future message can't ship in full. msg = "A" * 300 error = NewsAPIException(msg).to_dict()["error"] assert len(error) <= 200 assert error.endswith("...") def test_stored_message_attribute_is_not_mutated(self): # Only the serialized output is scrubbed; the raw message stays intact # for server-side diagnosis via logger.exception. raw = "boom: Authorization: Bearer sk-secret999value000" e = NewsAPIException(raw) assert e.to_dict()["error"] != raw assert e.message == raw def test_error_code_and_status_code_untouched(self): e = NewsAPIException( "Bearer sk-abc123DEF456ghi789", status_code=502, error_code="UPSTREAM", ) d = e.to_dict() assert d["error_code"] == "UPSTREAM" assert d["status_code"] == 502 def test_benign_details_pass_through_unchanged(self): # Structured values (IDs, ints) and benign free text in details are # not credential shapes, so they survive verbatim. e = NewsAPIException( "err", details={ "subscription_id": "abc-123", "min_limit": 1, "query": "how do I reset my password?", }, ) assert e.to_dict()["details"] == { "subscription_id": "abc-123", "min_limit": 1, "query": "how do I reset my password?", } def test_credential_in_details_string_leaf_redacted(self): # details can carry caller-supplied free text (e.g. the user query at # api.py:1071/1134); a credential shape in a string leaf is scrubbed. e = NewsAPIException( "err", details={ "query": "Authorization: Bearer sk-abc123DEF456ghi789", "count": 3, }, ) d = e.to_dict()["details"] assert "sk-abc123DEF456ghi789" not in d["query"] assert "REDACTED" in d["query"] # Non-string leaves pass through untouched. assert d["count"] == 3 def test_nested_details_are_sanitized_recursively(self): e = NewsAPIException( "err", details={ "outer": {"inner": ["?api_key=AIzaSyD-EXAMPLE_1234567890"]} }, ) leaf = e.to_dict()["details"]["outer"]["inner"][0] assert "AIzaSyD-EXAMPLE_1234567890" not in leaf assert "REDACTED" in leaf def test_stored_details_attribute_is_not_mutated(self): raw = {"query": "Bearer sk-secret999value000"} e = NewsAPIException("err", details=raw) assert "REDACTED" in e.to_dict()["details"]["query"] assert e.details["query"] == "Bearer sk-secret999value000" def test_tuple_detail_normalizes_to_list(self): # A tuple leaf serializes to a JSON array anyway; sanitize returns a # plain list so identity is irrelevant downstream. e = NewsAPIException("err", details={"pair": ("a", "b")}) assert e.to_dict()["details"]["pair"] == ["a", "b"] def test_namedtuple_detail_does_not_crash(self): # Regression guard: `type(value)()` would raise on a # namedtuple (constructor isn't `(iterable) -> instance`), which inside # the error handler would degrade a structured error into a bare 500. from collections import namedtuple Pair = namedtuple("Pair", ["a", "b"]) e = NewsAPIException( "err", details={"pair": Pair("Bearer sk-abc123DEF456ghi789", "x")} ) d = e.to_dict()["details"]["pair"] assert d == ["Bearer [REDACTED]", "x"] def test_subclass_message_sanitized_via_inherited_to_dict(self): # A subclass that interpolates a caller-supplied string still gets the # backstop, because to_dict() is inherited. e = SubscriptionCreationException( "Authorization: Bearer sk-abc123DEF456ghi789" ) error = e.to_dict()["error"] assert "sk-abc123DEF456ghi789" not in error assert error.startswith("Failed to create subscription:") # --- Specific exceptions --- class TestNewsFeatureDisabledException: """Tests for disabled feature exception.""" def test_default_message(self): e = NewsFeatureDisabledException() assert e.message == "News system is disabled" def test_custom_message(self): e = NewsFeatureDisabledException("Feature X disabled") assert e.message == "Feature X disabled" def test_status_code_503(self): e = NewsFeatureDisabledException() assert e.status_code == 503 def test_error_code(self): e = NewsFeatureDisabledException() assert e.error_code == "NEWS_DISABLED" def test_inherits_base(self): e = NewsFeatureDisabledException() assert isinstance(e, NewsAPIException) class TestInvalidLimitException: """Tests for invalid limit exception.""" def test_message_includes_limit(self): e = InvalidLimitException(0) assert "0" in e.message def test_status_code_400(self): e = InvalidLimitException(-1) assert e.status_code == 400 def test_error_code(self): e = InvalidLimitException(0) assert e.error_code == "INVALID_LIMIT" def test_details_include_provided_limit(self): e = InvalidLimitException(-5) assert e.details["provided_limit"] == -5 def test_details_include_min_limit(self): e = InvalidLimitException(0) assert e.details["min_limit"] == 1 def test_to_dict_has_details(self): e = InvalidLimitException(0) d = e.to_dict() assert d["details"]["provided_limit"] == 0 def test_inherits_base(self): assert issubclass(InvalidLimitException, NewsAPIException) class TestSubscriptionNotFoundException: """Tests for subscription not found exception.""" def test_message_includes_id(self): e = SubscriptionNotFoundException("sub-123") assert "sub-123" in e.message def test_status_code_404(self): e = SubscriptionNotFoundException("sub-1") assert e.status_code == 404 def test_error_code(self): e = SubscriptionNotFoundException("sub-1") assert e.error_code == "SUBSCRIPTION_NOT_FOUND" def test_details_include_subscription_id(self): e = SubscriptionNotFoundException("sub-abc") assert e.details["subscription_id"] == "sub-abc" class TestSubscriptionCreationException: """Tests for subscription creation exception.""" def test_message_prefixed(self): e = SubscriptionCreationException("query too long") assert "Failed to create subscription" in e.message assert "query too long" in e.message def test_status_code_500(self): e = SubscriptionCreationException("err") assert e.status_code == 500 def test_error_code(self): e = SubscriptionCreationException("err") assert e.error_code == "SUBSCRIPTION_CREATE_FAILED" def test_custom_details(self): e = SubscriptionCreationException("err", details={"query": "test"}) assert e.details == {"query": "test"} def test_none_details_becomes_empty(self): e = SubscriptionCreationException("err", details=None) assert e.details == {} class TestSubscriptionUpdateException: """Tests for subscription update exception.""" def test_message_includes_id_and_reason(self): e = SubscriptionUpdateException("sub-1", "invalid interval") assert "sub-1" in e.message assert "invalid interval" in e.message def test_status_code_500(self): e = SubscriptionUpdateException("sub-1", "err") assert e.status_code == 500 def test_error_code(self): e = SubscriptionUpdateException("sub-1", "err") assert e.error_code == "SUBSCRIPTION_UPDATE_FAILED" def test_details_include_subscription_id(self): e = SubscriptionUpdateException("sub-42", "err") assert e.details["subscription_id"] == "sub-42" class TestSubscriptionDeletionException: """Tests for subscription deletion exception.""" def test_message_includes_id(self): e = SubscriptionDeletionException("sub-1", "db locked") assert "sub-1" in e.message def test_status_code_500(self): e = SubscriptionDeletionException("sub-1", "err") assert e.status_code == 500 def test_error_code(self): e = SubscriptionDeletionException("sub-1", "err") assert e.error_code == "SUBSCRIPTION_DELETE_FAILED" def test_details_include_subscription_id(self): e = SubscriptionDeletionException("sub-7", "err") assert e.details["subscription_id"] == "sub-7" class TestDatabaseAccessException: """Tests for database access exception.""" def test_message_includes_operation(self): e = DatabaseAccessException("INSERT", "table locked") assert "INSERT" in e.message def test_status_code_500(self): e = DatabaseAccessException("SELECT", "err") assert e.status_code == 500 def test_error_code(self): e = DatabaseAccessException("DELETE", "err") assert e.error_code == "DATABASE_ERROR" def test_details_include_operation(self): e = DatabaseAccessException("UPDATE", "err") assert e.details["operation"] == "UPDATE" class TestNewsFeedGenerationException: """Tests for feed generation exception.""" def test_message_prefixed(self): e = NewsFeedGenerationException("timeout") assert "Failed to generate news feed" in e.message def test_status_code_500(self): e = NewsFeedGenerationException("err") assert e.status_code == 500 def test_error_code(self): e = NewsFeedGenerationException("err") assert e.error_code == "FEED_GENERATION_FAILED" def test_no_user_id_means_empty_details(self): e = NewsFeedGenerationException("err") assert e.details == {} def test_user_id_in_details(self): e = NewsFeedGenerationException("err", user_id="user-42") assert e.details["user_id"] == "user-42" class TestResearchProcessingException: """Tests for research processing exception.""" def test_message_prefixed(self): e = ResearchProcessingException("parse failed") assert "Failed to process research item" in e.message def test_error_code(self): e = ResearchProcessingException("err") assert e.error_code == "RESEARCH_PROCESSING_FAILED" def test_no_research_id_means_empty_details(self): e = ResearchProcessingException("err") assert e.details == {} def test_research_id_in_details(self): e = ResearchProcessingException("err", research_id="res-1") assert e.details["research_id"] == "res-1" class TestNotImplementedException: """Tests for not implemented exception.""" def test_message_includes_feature(self): e = NotImplementedException("live streaming") assert "live streaming" in e.message def test_status_code_501(self): e = NotImplementedException("x") assert e.status_code == 501 def test_error_code(self): e = NotImplementedException("x") assert e.error_code == "NOT_IMPLEMENTED" def test_details_include_feature(self): e = NotImplementedException("video analysis") assert e.details["feature"] == "video analysis" class TestInvalidParameterException: """Tests for invalid parameter exception.""" def test_message_includes_parameter_name(self): e = InvalidParameterException("limit", -1, "must be positive") assert "limit" in e.message def test_message_includes_description(self): e = InvalidParameterException("limit", -1, "must be positive") assert "must be positive" in e.message def test_status_code_400(self): e = InvalidParameterException("p", "v", "m") assert e.status_code == 400 def test_error_code(self): e = InvalidParameterException("p", "v", "m") assert e.error_code == "INVALID_PARAMETER" def test_details_include_parameter(self): e = InvalidParameterException("limit", -1, "bad") assert e.details["parameter"] == "limit" def test_details_include_value(self): e = InvalidParameterException("limit", -1, "bad") assert e.details["value"] == -1 class TestSchedulerNotificationException: """Tests for scheduler notification exception.""" def test_message_includes_action(self): e = SchedulerNotificationException("user_login", "timeout") assert "user_login" in e.message def test_status_code_500(self): e = SchedulerNotificationException("a", "m") assert e.status_code == 500 def test_error_code(self): e = SchedulerNotificationException("a", "m") assert e.error_code == "SCHEDULER_NOTIFICATION_FAILED" def test_details_include_action(self): e = SchedulerNotificationException("refresh", "m") assert e.details["action"] == "refresh" # --- Cross-cutting tests --- class TestExceptionHierarchy: """Test that all exceptions inherit from NewsAPIException.""" @pytest.mark.parametrize( "cls", [ NewsFeatureDisabledException, InvalidLimitException, SubscriptionNotFoundException, SubscriptionCreationException, SubscriptionUpdateException, SubscriptionDeletionException, DatabaseAccessException, NewsFeedGenerationException, ResearchProcessingException, NotImplementedException, InvalidParameterException, SchedulerNotificationException, ], ) def test_inherits_from_base(self, cls): assert issubclass(cls, NewsAPIException) @pytest.mark.parametrize( "cls", [ NewsFeatureDisabledException, InvalidLimitException, SubscriptionNotFoundException, SubscriptionCreationException, SubscriptionUpdateException, SubscriptionDeletionException, DatabaseAccessException, NewsFeedGenerationException, ResearchProcessingException, NotImplementedException, InvalidParameterException, SchedulerNotificationException, ], ) def test_inherits_from_exception(self, cls): assert issubclass(cls, Exception) class TestExceptionCatchability: """Test that exceptions can be caught by their base class.""" def test_catch_by_base_class(self): with pytest.raises(NewsAPIException): raise SubscriptionNotFoundException("x") def test_catch_by_exception(self): with pytest.raises(Exception): raise DatabaseAccessException("op", "msg") def test_specific_catch_works(self): with pytest.raises(InvalidLimitException): raise InvalidLimitException(0)