tests/test_settings_parity.py
Rama main · réplica de lectura del repositorio original.
"""Every configuration field is either editable in Admin → Settings, or is
listed here with a reason it is not.
The failure this prevents is quiet: somebody adds a setting to `settings.py`,
documents it in `configuration.md`, and it can only ever be changed by editing
YAML on the server — which is exactly the trip the settings page exists to save.
Nothing else notices, because nothing else knows the field was supposed to be
reachable.
Adding a field therefore forces a decision: expose it, or say why not.
"""
from __future__ import annotations
from pydantic import BaseModel
from app import configfile
from app.settings import PortfolioSettings
# Fields deliberately not editable from the page, and why. A reason each,
# because "not exposed" without one is indistinguishable from an oversight.
WITHHELD: dict[str, str] = {
"app_name": "Branding, fixed at build time. The page shows it as a fact.",
# Changing the database from inside the application that is using it is a
# way to lock yourself out of both. The wizard sets it once, on an install
# that has nothing to lose yet.
"database.type": "Set by the setup wizard; changing it live orphans the data.",
"database.path": "As above.",
"database.host": "As above.",
"database.port": "As above.",
"database.name": "As above.",
"database.user": "As above.",
"database.password": "As above — and a password field that renders its own "
"value is a password on a screen.",
"database.sslmode": "As above.",
"auth.cookie_name": "Renaming it signs everybody out, and nothing is gained.",
# Same reason as the database password: a field that renders its own value
# puts a live credential on a screen somebody may be sharing.
"auth.oidc.client_secret": "A credential. Set it in the file or the "
"environment, never on a page.",
# Paths the process reads and writes. A typo here breaks imports until
# somebody edits YAML anyway, which is the opposite of the point.
"imports.templates_dir": "A server path, not a preference.",
"imports.visual_dir": "A server path, not a preference.",
"imports.staging_dir": "A server path, not a preference.",
# Per-broker column mappings: structured data with its own editor.
"imports.brokers.kind": "Edited in the broker designer, not as a scalar.",
"imports.brokers.exchange": "As above.",
"imports.brokers.currency": "As above.",
"imports.brokers.date_format": "As above.",
"imports.brokers.columns": "As above.",
"imports.brokers.actions": "As above.",
"imports.brokers.times_zone": "As above.",
}
def _fields(model: type[BaseModel], prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]:
"""Every leaf field in the settings tree, as a dotted path.
Nested models are walked; the two dict-of-model fields are not, because
their keys are broker names rather than settings.
"""
out: list[tuple[str, ...]] = []
for name, field in model.model_fields.items():
path = prefix + (name,)
annotation = field.annotation
nested = None
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
nested = annotation
else:
for arg in getattr(annotation, "__args__", ()):
if isinstance(arg, type) and issubclass(arg, BaseModel):
nested = arg
if nested is not None:
out.extend(_fields(nested, path))
else:
out.append(path)
return out
def test_every_setting_is_editable_or_withheld_with_a_reason():
editable = {".".join(o.path) for o in configfile.OPTIONS}
unaccounted = [
".".join(path) for path in _fields(PortfolioSettings)
if ".".join(path) not in editable and ".".join(path) not in WITHHELD
]
assert not unaccounted, (
"Add each of these to configfile.OPTIONS, or to WITHHELD with a reason: "
f"{unaccounted}")
def test_nothing_is_both_editable_and_withheld():
"""A field in both lists means the reason is stale — it IS reachable, and
the sentence saying why it isn't is now misinformation."""
editable = {".".join(o.path) for o in configfile.OPTIONS}
assert not (editable & set(WITHHELD))
def test_every_option_names_a_real_setting():
"""An option whose path no longer exists renders an empty box that saves a
key nothing reads. Renaming a settings field is exactly when this happens.
"""
known = {".".join(path) for path in _fields(PortfolioSettings)}
# Feature toggles are generated from features.FEATURES onto a dict-shaped
# settings field, so they are legitimately absent from the leaf walk.
stray = [".".join(o.path) for o in configfile.OPTIONS
if ".".join(o.path) not in known and o.path[0] != "features"]
assert not stray
def test_optional_settings_accept_being_left_empty():
"""`optional` is what lets a box be cleared rather than only re-typed. A
required field would refuse, which for rp_id would mean passkeys could be
configured but never unconfigured.
"""
for option in configfile.OPTIONS:
if option.optional:
assert configfile.coerce(option, "") in (None, [])
# --------------------------------------------------------------------------- #
# The defaults the shipped config.yaml writes down
# --------------------------------------------------------------------------- #
# config.yaml documents each setting with its default commented out, and the
# pull request template asks for exactly that. Nothing compared the two: a
# mutation run changed every default (15 minutes to 16, 3 a.m. to 4) and no
# test failed, while the file went on telling people the old value.
# Commented lines that show something to copy rather than the default, and why.
EXAMPLES: dict[str, str] = {
"database.host": "Where a Postgres server might be; the default suits one cluster.",
"database.name": "An example name; the default is empty, which the wizard asks for.",
"database.user": "As above.",
"price_feed.timezone": "Unset follows the top-level timezone; the line shows a zone.",
"auth.webauthn.rp_id": "The install's own domain; unset uses the request's.",
"auth.webauthn.rp_name": "As above, for the name a passkey prompt shows.",
"auth.oidc.issuer": "The provider's address, which only the operator knows.",
"auth.oidc.client_id": "As above.",
"auth.oidc.client_secret": "As above, and a credential.",
"auth.oidc.redirect_uri": "As above.",
}
def _documented_defaults() -> dict[str, object]:
"""Every commented `key: value` line in config.yaml, by dotted path.
A commented line's depth is where its key starts once the `# ` is taken
off, so `# window_minutes: 15` under `# rate_limit:` under `auth:` is
auth.rate_limit.window_minutes. Prose comments do not start with a
lower-case key and a colon, so they are passed over."""
import re
from pathlib import Path
import yaml
text = (Path(__file__).resolve().parent.parent / "config.yaml").read_text(encoding="utf-8")
stack: list[tuple[int, str]] = []
out: dict[str, object] = {}
line = re.compile(r"^(\s*)(#\s)?(\s*)([a-z_][a-z0-9_]*):(?:\s+(.*?))?(?:\s+#.*)?$")
for raw in text.splitlines():
found = line.match(raw)
if not found:
continue
lead, hashed, extra, key, value = found.groups()
depth = len(lead) + (len(extra) if hashed else 0)
while stack and stack[-1][0] >= depth:
stack.pop()
if not value:
stack.append((depth, key))
continue
if hashed:
out[".".join([k for _, k in stack] + [key])] = yaml.safe_load(value)
return out
def test_every_default_config_yaml_writes_down_is_the_default():
defaults = PortfolioSettings.model_construct()
compared = 0
for dotted, written in _documented_defaults().items():
value = defaults
for part in dotted.split("."):
value = getattr(value, part, KeyError)
if value is KeyError or dotted in EXAMPLES:
continue
compared += 1
assert value == written, f"config.yaml says {dotted}: {written!r}; the default is {value!r}"
assert compared >= 30, "the sweep found too few documented defaults to mean anything"
def test_every_example_in_the_list_is_still_in_the_file():
stale = sorted(set(EXAMPLES) - set(_documented_defaults()))
assert not stale, f"EXAMPLES names lines config.yaml no longer has: {stale}"
def test_the_feed_time_the_shipped_file_sets_is_the_codes_own():
"""The daily run's time is written out uncommented, so it is not among the
commented defaults above: an install without the file must still run at
the same time as one with it."""
from pathlib import Path
import yaml
shipped = yaml.safe_load((Path(__file__).resolve().parent.parent / "config.yaml").read_text(encoding="utf-8"))
defaults = PortfolioSettings.model_construct().price_feed
assert (shipped["price_feed"]["hour"], shipped["price_feed"]["minute"]) == (
defaults.hour, defaults.minute)