diff --git a/tests/test_formatters_fields.py b/tests/test_formatters_fields.py new file mode 100644 index 0000000..e428a13 --- /dev/null +++ b/tests/test_formatters_fields.py @@ -0,0 +1,72 @@ +"""Optional fields and value handling in the JSON formatters (issue #137).""" +import json +import logging + +import pytest + +import json_logging +from json_logging.formatters import BaseJSONFormatter, JSONLogFormatter + + +@pytest.fixture +def component_settings(): + """Restore the module level component settings, which are read when a formatter is constructed.""" + saved = (json_logging.COMPONENT_ID, json_logging.COMPONENT_NAME, json_logging.COMPONENT_INSTANCE_INDEX) + # base_object_common is a class level dict that formatter construction writes into, so restore it as well + saved_common = dict(BaseJSONFormatter.base_object_common) + for key in ("component_id", "component_name", "component_instance_idx"): + BaseJSONFormatter.base_object_common.pop(key, None) + yield + BaseJSONFormatter.base_object_common.clear() + BaseJSONFormatter.base_object_common.update(saved_common) + json_logging.COMPONENT_ID, json_logging.COMPONENT_NAME, json_logging.COMPONENT_INSTANCE_INDEX = saved + + +def _format(**extra): + record = logging.LogRecord("formatter-test", logging.INFO, __file__, 1, "hello", None, None) + for key, value in extra.items(): + setattr(record, key, value) + return json.loads(JSONLogFormatter().format(record)) + + +def test_component_settings_are_added_when_set(component_settings): + json_logging.COMPONENT_ID = "id-1" + json_logging.COMPONENT_NAME = "name-1" + json_logging.COMPONENT_INSTANCE_INDEX = 2 + + output = _format() + + assert output["component_id"] == "id-1" + assert output["component_name"] == "name-1" + assert output["component_instance_idx"] == 2 + + +def test_component_settings_are_omitted_by_default(component_settings): + output = _format() + + assert "component_id" not in output + assert "component_name" not in output + assert "component_instance_idx" not in output + + +def test_non_serialisable_extra_value_falls_back_to_repr(): + class Unserialisable: + def __repr__(self): + return "" + + output = _format(thing=Unserialisable()) + + assert output["thing"] == "" + + +def test_props_dict_is_merged_into_the_root_of_the_log(): + output = _format(props={"user": "alice", "attempt": 3}) + + assert output["user"] == "alice" + assert output["attempt"] == 3 + + +def test_props_that_is_not_a_dict_is_not_merged(): + output = _format(props="not-a-dict") + + assert "not-a-dict" not in output.values() diff --git a/tests/test_init_validation.py b/tests/test_init_validation.py new file mode 100644 index 0000000..3323878 --- /dev/null +++ b/tests/test_init_validation.py @@ -0,0 +1,75 @@ +"""Argument validation and error paths of the public initialisation API (issue #137).""" +import logging + +import pytest +from helpers.imports import undo_imports_from_package + + +@pytest.fixture +def fresh_json_logging(): + """Import a pristine json_logging so module level init state does not leak between tests.""" + undo_imports_from_package("json_logging") + import json_logging + yield json_logging + undo_imports_from_package("json_logging") + + +def test_init_framework_twice_raises(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(RuntimeError, match="more than once"): + fresh_json_logging.init_flask() + + +def test_init_rejects_custom_formatter_that_is_not_a_formatter(fresh_json_logging): + with pytest.raises(ValueError, match="custom_formatter"): + fresh_json_logging.init_non_web(custom_formatter=dict) + + +def test_init_rejects_unknown_framework(fresh_json_logging): + with pytest.raises(RuntimeError, match="not a supported framework"): + fresh_json_logging.init_non_web(framework_name="no-such-framework") + + +def test_init_request_instrument_before_init_raises(fresh_json_logging): + with pytest.raises(RuntimeError, match="init the logging first"): + fresh_json_logging.init_request_instrument(app=object()) + + +def test_init_request_instrument_after_non_web_init_raises(fresh_json_logging): + fresh_json_logging.init_non_web() + with pytest.raises(RuntimeError, match="init the logging first"): + fresh_json_logging.init_request_instrument(app=object()) + + +def test_init_request_instrument_rejects_bad_custom_formatter(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(ValueError, match="custom_formatter"): + fresh_json_logging.init_request_instrument(app=object(), custom_formatter=dict) + + +def test_init_request_instrument_rejects_bad_request_response_dto_class(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(ValueError, match="request_response_dto_class"): + fresh_json_logging.init_request_instrument(app=object(), request_response_dto_class=dict) + + +def test_get_request_logger_before_init_raises(fresh_json_logging): + with pytest.raises(RuntimeError, match="only available"): + fresh_json_logging.get_request_logger() + + +def test_config_root_logger_without_handlers_logs_error_and_returns(fresh_json_logging, monkeypatch): + fresh_json_logging.ENABLE_JSON_LOGGING = True + monkeypatch.setattr(logging.root, "handlers", []) + errors = [] + monkeypatch.setattr(fresh_json_logging._logger, "error", lambda msg, *a, **kw: errors.append(msg)) + + def fail(*args, **kwargs): + raise AssertionError("root logger must not be touched when it has no handlers") + + monkeypatch.setattr(fresh_json_logging.util, "update_formatter_for_loggers", fail) + + fresh_json_logging.config_root_logger() + + assert len(errors) == 1 + assert "No logging handlers found" in errors[0] diff --git a/tests/test_util_validation.py b/tests/test_util_validation.py new file mode 100644 index 0000000..082ded3 --- /dev/null +++ b/tests/test_util_validation.py @@ -0,0 +1,43 @@ +"""Error paths of the helpers in json_logging.util (issue #137).""" +import logging + +import pytest + +from json_logging import util + + +def test_update_formatter_for_loggers_rejects_non_logger(): + with pytest.raises(RuntimeError): + util.update_formatter_for_loggers(["not-a-logger"], logging.Formatter) + + +def test_update_formatter_for_loggers_replaces_other_formatters_only(): + class Wanted(logging.Formatter): + pass + + logger = logging.Logger("util-validation-test") + replaced = logging.StreamHandler() + kept = logging.StreamHandler() + already_wanted = Wanted() + kept.setFormatter(already_wanted) + logger.addHandler(replaced) + logger.addHandler(kept) + + util.update_formatter_for_loggers([logger], Wanted) + + assert isinstance(replaced.formatter, Wanted) + assert kept.formatter is already_wanted + + +def test_validate_subclass_accepts_subclass(): + assert util.validate_subclass(logging.StreamHandler, logging.Handler) is True + + +def test_validate_subclass_rejects_non_subclass(): + with pytest.raises(RuntimeError, match="is not a subclass of"): + util.validate_subclass(dict, logging.Handler) + + +@pytest.mark.parametrize("value, default, expected", [("7", 1, 7), ("x", 1, 1), (None, 3, 3), (4, 0, 4)]) +def test_parse_int_falls_back_to_default(value, default, expected): + assert util.parse_int(value, default) == expected