Ctrl K
ComunidadGuías…

tests/test_config_diff.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""What the wizard asks somebody to add when it cannot write the file itself.

Issue #18: a reader who had written their own config.yaml — timezone set, price
feed on — reached the last step and was told to set both. The wizard applies
its choices to the RUNNING settings before drawing that page, so comparing
against those says everything matches; the comparison has to be against what
survives a restart.
"""

from __future__ import annotations

import datetime as dt

import pytest
from hypothesis import given
from hypothesis import strategies as st

from app import configfile


@pytest.fixture
def config_file(tmp_path, monkeypatch):
    """A real config.yaml on disk, which is what `load()` reads."""
    path = tmp_path / "config.yaml"
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    return path


def test_a_value_the_file_already_has_is_not_asked_for(config_file):
    config_file.write_text("timezone: Australia/Melbourne\n", encoding="utf-8")

    outstanding = configfile.not_yet_provided({"timezone": "Australia/Melbourne"})

    assert outstanding == {}


def test_a_value_the_file_has_differently_is_asked_for(config_file):
    config_file.write_text("timezone: Europe/Dublin\n", encoding="utf-8")

    outstanding = configfile.not_yet_provided({"timezone": "Australia/Melbourne"})

    assert outstanding == {"timezone": "Australia/Melbourne"}


def test_only_the_missing_half_of_a_nested_block_is_asked_for(config_file):
    """The reporter's case: the feed was already on, the hour was not. Being
    told to set both is what made the page read as though nothing was read."""
    config_file.write_text("price_feed:\n  enabled: true\n", encoding="utf-8")

    outstanding = configfile.not_yet_provided(
        {"price_feed": {"enabled": True, "hour": 18}})

    assert outstanding == {"price_feed": {"hour": 18}}


def test_a_booleans_spelling_does_not_make_it_look_different(config_file):
    """YAML gives back `True` where a form gave "true". Both mean the same, and
    a diff that reported them as different would send somebody to change a line
    into itself."""
    config_file.write_text("price_feed:\n  enabled: true\n", encoding="utf-8")

    assert configfile.not_yet_provided({"price_feed": {"enabled": True}}) == {}


def test_a_value_an_environment_variable_supplies_is_not_asked_for(
    config_file, monkeypatch
):
    """An env var wins at load and survives a restart, so there is nothing to
    write down. Telling somebody to add it would be telling them to duplicate
    a setting that is already winning."""
    config_file.write_text("", encoding="utf-8")
    monkeypatch.setenv("APP_TIMEZONE", "Australia/Melbourne")

    assert configfile.not_yet_provided({"timezone": "Australia/Melbourne"}) == {}


def test_an_empty_file_is_asked_for_everything(config_file):
    config_file.write_text("", encoding="utf-8")
    wanted = {"timezone": "Australia/Melbourne",
              "price_feed": {"enabled": True}}

    assert configfile.not_yet_provided(wanted) == wanted


def test_a_missing_file_is_asked_for_everything(tmp_path, monkeypatch):
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(tmp_path / "nothing.yaml"))

    assert configfile.not_yet_provided({"timezone": "X"}) == {"timezone": "X"}


def test_an_unreadable_file_asks_for_everything_rather_than_guessing(
    config_file, monkeypatch
):
    """Unreadable is not "absent". Saying "nothing to change" because we could
    not look would be worse than asking for something already there."""
    config_file.write_text("timezone: Australia/Melbourne\n", encoding="utf-8")

    def refuse():
        raise PermissionError("nope")

    monkeypatch.setattr(configfile, "load", refuse)

    assert configfile.not_yet_provided({"timezone": "Australia/Melbourne"}) == {
        "timezone": "Australia/Melbourne"}


def test_a_date_compares_by_what_it_reads_as(config_file):
    """`backfill_start` is a date in the draft and a date in YAML; they must
    not look different for being different Python objects."""
    config_file.write_text("price_feed:\n  backfill_start: 2020-01-01\n", encoding="utf-8")

    outstanding = configfile.not_yet_provided(
        {"price_feed": {"backfill_start": dt.date(2020, 1, 1)}})

    assert outstanding == {}


# --------------------------------------------------------------------------- #
# What is written has to be readable by the thing that reads it
# --------------------------------------------------------------------------- #

@pytest.mark.parametrize("value", ["off", "on", "no", "yes", "true", "false", "null"])
def test_a_word_yaml_would_read_as_a_boolean_is_quoted(config_file, value):
    """We WRITE with ruamel (YAML 1.2, where `off` is a string) and the app
    READS through pydantic-settings, which uses PyYAML (YAML 1.1, where it is
    `False`). Unquoted, choosing "off" in Admin → Settings produced a file the
    application could not start from — a settings page able to stop the next
    boot. Reported as a docs typo in issue #23; it was not one.
    """
    config_file.write_text("auth:\n  oidc:\n    enabled: true\n", encoding="utf-8")
    configfile.save({"auth.oidc.provisioning": value})

    written = config_file.read_text(encoding="utf-8")
    assert f"provisioning: '{value}'" in written


def test_the_written_file_survives_the_loader_that_reads_it(config_file):
    """The round trip, rather than the quoting: what matters is that the file
    the settings page writes is one the application can boot from."""
    import yaml as pyyaml  # what pydantic-settings uses

    config_file.write_text("auth:\n  oidc:\n    enabled: true\n", encoding="utf-8")
    configfile.save({"auth.oidc.provisioning": "off"})

    reloaded = pyyaml.safe_load(config_file.read_text(encoding="utf-8"))

    assert reloaded["auth"]["oidc"]["provisioning"] == "off"


# The test above tried "off". YAML 1.1 reads more than the boolean words as
# something other than text: base 60 (a time, or an IPv6 address written out
# in full), binary, numbers with underscores, the merge and value keys. Any of
# them typed into a text setting, or a trusted proxy written out in full, came
# back as a number, or as nothing the loader could build.
AWKWARD = ["2001:0:0:0:0:0:0:1", "12:30", "1:20:30", "190:20:30.15", "0b1010", "1_000",
           "1_000.5", "0x1F", "0o17", "017", "1.5", ".5", "1e3", "+12", "-7", "=", "<<",
           "~", "null", "off", "Yes", "NO", "2026-01-05", "2026-01-05 10:00:00", ".inf",
           ".NaN", "-.inf", "@home", "!tag", "&anchor", "*alias", "a: b", "- x", "#hash",
           "x #y", "'q'", '"dq"', "%percent", "`tick", "|", ">", "?", "[1]", "{a: 1}",
           "10.0.0.0/8", "::1", "fe80::1%eth0", "https://id.example.com/realms/home"]
TEXT = [o for o in configfile.OPTIONS if o.kind == "text" and "timezone" not in o.path]
LISTS = [o for o in configfile.OPTIONS if o.kind == "list"]
typed = st.one_of(st.sampled_from(AWKWARD), st.text(
    st.characters(blacklist_categories=("Cs", "Cc", "Zl", "Zp")), min_size=1, max_size=12))


@given(option=st.sampled_from(TEXT), raw=typed)
def test_any_text_saved_is_the_text_the_app_reads_back(config_file, option, raw):
    import yaml as pyyaml  # what pydantic-settings reads with

    value = configfile.coerce(option, raw)
    config_file.write_text("auth:\n  oidc:\n    enabled: true\n", encoding="utf-8")
    configfile.save({option.name: value})

    read = pyyaml.safe_load(config_file.read_text(encoding="utf-8"))
    for key in option.path:
        read = read[key]
    assert read == value


@given(option=st.sampled_from(LISTS), items=st.lists(typed, min_size=1, max_size=4))
def test_any_list_saved_is_the_list_the_app_reads_back(config_file, option, items):
    import yaml as pyyaml

    value = configfile.coerce(option, "\n".join(items))
    config_file.write_text("auth:\n  oidc:\n    enabled: true\n", encoding="utf-8")
    configfile.save({option.name: value})

    read = pyyaml.safe_load(config_file.read_text(encoding="utf-8"))
    for key in option.path:
        read = read[key]
    assert read == value


def test_an_ordinary_string_is_left_alone(config_file):
    """Quoting everything would rewrite half the file on every save and lose
    the shape somebody chose."""
    config_file.write_text("timezone: UTC\n", encoding="utf-8")
    configfile.save({"timezone": "Australia/Melbourne"})

    assert "timezone: Australia/Melbourne" in config_file.read_text(encoding="utf-8")