"""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")