tests/test_settings.py
Rama main · réplica de lectura del repositorio original.
"""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/<app_name>.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)