"""How configuration is resolved. The precedence is **environment > YAML file > field default**, and the surprising part — the one that cost a wrong test when it was first written — is where constructor keyword arguments sit: *last*, behind the YAML file. So PortfolioSettings(timezone="Europe/Berlin") does not necessarily produce a Berlin timezone. It is the `init_settings` source, and `settings_customise_sources` deliberately puts it after the file so that a deployment's config.yaml cannot be overridden by a stray default in code. Anything constructing settings in a test must go through the environment instead, or it is asserting nothing. These tests read against `tests/config.test.yaml`, which differs from the field defaults on purpose (log_level WARNING vs INFO, 3 login attempts vs 8, the price feed off rather than on) — those differences are what make each layer observable. """ from __future__ import annotations import os import pytest from pydantic import ValidationError from pydantic_settings import SettingsConfigDict from app.settings import DEFAULT_TIMEZONE, PortfolioSettings from appcore.config import DatabaseSettings def _isolate_database_env(monkeypatch) -> None: """Clear every APP_DATABASE__* variable. conftest sets these when the suite runs against Postgres, and they are higher priority than anything a test can express — so a test observing the file or default layers has to remove them all, not just the one it happens to remember. """ for name in [k for k in os.environ if k.startswith("APP_DATABASE__")]: monkeypatch.delenv(name, raising=False) # --------------------------------------------------------------------------- # # The three layers # --------------------------------------------------------------------------- # def test_a_field_default_applies_when_nothing_else_sets_it(): # `auth.cookie_name` appears in neither the environment nor the test YAML. assert PortfolioSettings().auth.cookie_name == "pf_session" def test_the_yaml_file_beats_the_field_default(): settings = PortfolioSettings() assert settings.log_level == "WARNING" # field default INFO assert settings.auth.rate_limit.max_attempts == 3 # field default 8 assert settings.price_feed.enabled is False # field default True def test_the_environment_beats_the_yaml_file(monkeypatch): monkeypatch.setenv("APP_LOG_LEVEL", "DEBUG") assert PortfolioSettings().log_level == "DEBUG" # YAML says WARNING def test_a_nested_value_is_reachable_through_the_double_underscore(monkeypatch): """`APP_` prefix, `__` between levels — this is how the database password reaches the app from a Secret without ever being written to the file.""" monkeypatch.setenv("APP_AUTH__RATE_LIMIT__MAX_ATTEMPTS", "11") assert PortfolioSettings().auth.rate_limit.max_attempts == 11 # YAML says 3 def test_constructor_arguments_lose_to_the_yaml_file(): """The trap, pinned deliberately. A deployment's config.yaml must win over a value passed in code, so `init_settings` is the lowest-priority source. The consequence is that building settings with kwargs in a test proves nothing — go through the environment. """ settings = PortfolioSettings(log_level="CRITICAL") assert settings.log_level == "WARNING" # the file, not the argument def test_constructor_arguments_do_apply_where_the_file_is_silent(): """…and they are not ignored outright: with no file entry and no environment variable, an argument is all there is.""" assert PortfolioSettings(auth={"cookie_name": "custom_cookie"}).auth.cookie_name == ( "custom_cookie" ) def test_the_environment_beats_a_constructor_argument(monkeypatch): monkeypatch.setenv("APP_AUTH__COOKIE_NAME", "from_env") assert PortfolioSettings(auth={"cookie_name": "from_code"}).auth.cookie_name == "from_env" # --------------------------------------------------------------------------- # # Failing loudly # --------------------------------------------------------------------------- # def test_an_unknown_key_is_refused_rather_than_ignored(): """`extra="forbid"`: a typo in the config stops the app at boot instead of silently keeping the default, which is the failure nobody notices. Note this catches a bad key in the FILE (or in code), not a stray `APP_*` environment variable — the environment source only reads variables matching a declared field, so a misspelled one is invisible rather than fatal. That asymmetry is worth knowing before trusting a typo to be caught.""" with pytest.raises(ValidationError): PortfolioSettings(timezoen="Australia/Perth") # deliberate typo def test_a_misspelled_environment_variable_is_silently_ignored(monkeypatch): """The other half of the asymmetry above, pinned so nobody assumes otherwise: this is why the admin settings page validates what it writes.""" monkeypatch.setenv("APP_TIMEZOEN", "Australia/Perth") assert PortfolioSettings().timezone == DEFAULT_TIMEZONE def test_postgres_requires_a_name_and_user(): with pytest.raises(ValidationError): DatabaseSettings(type="postgres", name="", user="") def test_postgres_fields_without_an_explicit_type_are_refused(): """The footgun this guard exists for: a config carrying Postgres fields but no `type` would default to sqlite and boot on an empty file, losing every row silently.""" with pytest.raises(ValidationError, match="explicitly"): DatabaseSettings(name="portfolio", user="portfolio") # --------------------------------------------------------------------------- # # Derived values # --------------------------------------------------------------------------- # def test_the_timezone_defaults_to_the_documented_constant(): """One constant, used by both the settings default and app/clock.py, so they cannot drift apart.""" assert PortfolioSettings(app_name="x").timezone == DEFAULT_TIMEZONE def test_the_sqlite_path_is_derived_from_the_app_name(monkeypatch): """With no path configured anywhere, the database lands at /data/.db — which must be a mounted volume, not the ephemeral container filesystem. Isolated from tests/config.test.yaml (which sets a path of its own) by pointing the settings class at a file that does not exist: the only way to observe the field-default layer on its own. """ _isolate_database_env(monkeypatch) class Isolated(PortfolioSettings): model_config = SettingsConfigDict( env_prefix="APP_", env_nested_delimiter="__", yaml_file="/nonexistent/config.yaml", extra="forbid", ) assert Isolated(app_name="demo").database.path == "/data/demo.db" # --------------------------------------------------------------------------- # # Surviving a tidy-up # --------------------------------------------------------------------------- # def test_a_block_with_every_line_commented_out_still_starts(tmp_path, monkeypatch): """Commenting out the last option under a heading leaves the heading parsing as null, not as an absent key. Refusing to start on that would mean a config file that breaks by being tidied — which is how the shipped file was written before this was caught. """ path = tmp_path / "config.yaml" path.write_text( "app_name: portfolio\n" "auth:\n" " # rate_limit:\n" " # max_attempts: 8\n" "price_feed:\n" " # enabled: true\n" , encoding="utf-8") _isolate_database_env(monkeypatch) class Isolated(PortfolioSettings): model_config = SettingsConfigDict( env_prefix="APP_", env_nested_delimiter="__", yaml_file=str(path), extra="forbid", ) settings = Isolated() assert settings.auth.rate_limit.max_attempts == 8 # the default, not a crash assert settings.price_feed.enabled is True def test_the_shipped_config_file_loads(monkeypatch): """The documented config.yaml that ships with the app must actually work — it is the first thing anyone deploying this will read and copy.""" from pathlib import Path as _Path shipped = _Path(__file__).parent.parent / "config.yaml" _isolate_database_env(monkeypatch) class Shipped(PortfolioSettings): model_config = SettingsConfigDict( env_prefix="APP_", env_nested_delimiter="__", yaml_file=str(shipped), extra="forbid", ) settings = Shipped() assert settings.app_name == "stocktake" assert settings.database.type == "sqlite" # Commented-out options fall through to their documented defaults. assert settings.log_level == "INFO" assert settings.auth.session_ttl_days == 30 assert settings.auth.rate_limit.max_attempts == 8 # The shipped file carries NO broker overrides — a config override wins # over the file permanently, so shipping copies would freeze them. What # must still be true is that both formats are available, from the files. assert settings.imports.brokers == {} assert sorted(settings.imports.brokers_available()) == ["commsec", "selfwealth"] # # Starting with no configuration at all. Every setting has a default, # including `app_name`, so the first-run wizard can boot and ask — # decisions.md #33. class _NoFileSettings(PortfolioSettings): """PortfolioSettings pointed at a config file that does not exist. The real class bakes `yaml_file` in at import from APP_CONFIG_FILE, so changing the environment in a test cannot move it — a subclass is the only honest way to observe the no-file case. """ model_config = SettingsConfigDict(yaml_file="/nonexistent/portfolio.yaml") def test_the_app_starts_with_no_config_file(monkeypatch): """The premise the whole wizard rests on. If this fails, a fresh container dies at import and there is no page to show anybody.""" _isolate_database_env(monkeypatch) settings = _NoFileSettings() assert settings.app_name == "stocktake" assert settings.timezone == DEFAULT_TIMEZONE def test_what_it_boots_into_is_a_working_app(monkeypatch): """Not merely that it starts — that the defaults are a usable install, and a safe one: plain HTTP assumed, and no proxy believed until told.""" _isolate_database_env(monkeypatch) settings = _NoFileSettings() assert settings.price_feed.enabled is True assert settings.auth.session_idle_minutes == 60 assert settings.auth.cookie_secure is False assert settings.auth.trusted_proxies == [] assert settings.maintenance.enabled is True assert settings.imports.max_upload_mb == 10 def test_the_compose_copy_of_the_config_has_not_drifted(): """deploy/compose/config.yaml must be the same file as the shipped default. It is a reference copy — the compose README tells people it documents every option — so when it drifts it documents an app that does not exist. It had: example hostnames the root file did not, and it was missing the `features:` and `ocr:` blocks entirely, so two shipped features were undocumented for anyone reading the copy rather than the original. """ from pathlib import Path root = Path(__file__).resolve().parent.parent shipped = (root / "config.yaml").read_text(encoding="utf-8") copy = (root / "deploy" / "compose" / "config.yaml").read_text(encoding="utf-8") assert copy == shipped, ( "deploy/compose/config.yaml has drifted from config.yaml — copy the " "root file over it rather than editing one of them") def test_the_annotated_default_config_ships_for_the_wizard_to_write_from(): """The wizard produces a config by editing this file, so it must exist and still parse. The Dockerfile copies it in as config.default.yaml.""" from pathlib import Path from ruamel.yaml import YAML default = Path(__file__).resolve().parent.parent / "config.yaml" assert default.is_file(), "the annotated default config must ship" # Almost every line is a comment — that is the point of it — but it has to # be valid YAML that loads to a mapping or to nothing. parsed = YAML(typ="safe").load(default.read_text(encoding="utf-8")) assert parsed is None or isinstance(parsed, dict) # --------------------------------------------------------------------------- # # A section whose every line is commented out # --------------------------------------------------------------------------- # # YAML reads a heading with nothing under it as null, and settings.py treats a # null section as "use the defaults" rather than refusing to start — a config # file must not break by being tidied. The rule was applied to six sections # by name and missed four: `oidc:` beside `webauthn:`, `imports.ocr`, # `imports.brokers` and `database`. Each is now walked from the model. def _sections(model, prefix=()): """Every field holding a settings block or a dict of them, by path.""" from pydantic import BaseModel for name, field in model.model_fields.items(): annotation = field.annotation args = getattr(annotation, "__args__", ()) nested = annotation if isinstance(annotation, type) and issubclass(annotation, BaseModel) \ else next((a for a in args if isinstance(a, type) and issubclass(a, BaseModel)), None) mapping = getattr(annotation, "__origin__", None) is dict if nested is not None or mapping: yield prefix + (name,) if nested is not None and not mapping: yield from _sections(nested, prefix + (name,)) @pytest.mark.parametrize("path", list(_sections(PortfolioSettings)), ids=".".join) def test_a_section_left_empty_means_its_defaults(path, tmp_path, monkeypatch): """A real file, the heading kept and every line under it commented out, read with nothing else in play: the suite's own environment and config file would otherwise answer for `database` and `imports.brokers`.""" _isolate_database_env(monkeypatch) lines = [" " * depth + key + ":" for depth, key in enumerate(path)] lines.append(" " * len(path) + "# enabled: true") config = tmp_path / "config.yaml" config.write_text("\n".join(lines) + "\n", encoding="utf-8") class Tidied(PortfolioSettings): model_config = SettingsConfigDict(yaml_file=str(config)) loaded, expected = Tidied(), _NoFileSettings() for key in path: loaded, expected = getattr(loaded, key), getattr(expected, key) assert loaded == expected def test_the_sections_walked_are_the_ones_there_are(): walked = {".".join(p) for p in _sections(PortfolioSettings)} assert {"database", "auth.oidc", "auth.webauthn", "imports.ocr", "imports.brokers", "price_feed"} <= walked # --------------------------------------------------------------------------- # # What the last mutation pass found unchecked # --------------------------------------------------------------------------- # def test_a_broker_formats_currency_is_taken_however_it_is_typed(): from app.settings import BrokerFormat assert BrokerFormat(kind="mapped", currency=" usd ").currency == "USD" def test_an_imports_block_written_down_is_kept(tmp_path, monkeypatch): """The other half of an emptied section meaning its defaults: a section with something in it is not emptied.""" _isolate_database_env(monkeypatch) path = tmp_path / "config.yaml" path.write_text("imports:\n ocr:\n enabled: false\n", encoding="utf-8") class Written(PortfolioSettings): model_config = SettingsConfigDict(yaml_file=str(path)) assert Written().imports.ocr.enabled is False def test_an_installed_broker_format_the_model_refuses_is_skipped_and_named(tmp_path, caplog): import logging from app.settings import ImportSettings (tmp_path / "brokers").mkdir() (tmp_path / "brokers" / "bad.yaml").write_text( "kind: mapped\nexchange: ASX\ncurrency: AUSD\ncolumns: {}\n", encoding="utf-8") caplog.set_level(logging.WARNING, logger="app.settings") formats = ImportSettings(templates_dir=str(tmp_path)).brokers_available() assert "bad" not in formats assert any(r.getMessage().startswith("ignoring broker format bad: ") for r in caplog.records)