second-brain/tests/test_config_overlay.py
Travis Herbranson d40f25c99d config: load settings.local.toml as a section-level overlay
The base settings.toml is tracked; the new sibling settings.local.toml
is gitignored (entry already at .gitignore:32 — it was aspirational
until now, since the loader only read one file).

`load_config()` now overlays the local file on top of the base via a
new `_overlay_sections` helper that merges one level deep: for each
section in the local file, its keys are merged onto the base section's
keys, so a local `[database] url = "..."` no longer wipes other
`[database]` keys in the base. Sections present in only one file pass
through untouched.

Overlay is resolved as a sibling of whichever base file was chosen, so
SECOND_BRAIN_CONFIG=/etc/sb/settings.toml also picks up the matching
/etc/sb/settings.local.toml.

This is the dev-side ergonomic fix: Travis's interactive shell can keep
the lovebug password in a gitignored, 0600 settings.local.toml instead
of needing SECOND_BRAIN_DATABASE_URL exported on every manual `process`
run. Existing _merge is untouched (shallow), so the per-section
property merges in Config keep current semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:09:57 -04:00

166 lines
5.4 KiB
Python

"""Tests for the settings.local.toml overlay loader.
The overlay is the dev-side path for keeping host-specific secrets
(notably `[database].url`) out of the tracked `settings.toml` without
forcing every interactive shell to export env vars. The footgun this
test suite guards against: a one-key local override (e.g. a single
`[database] url = "..."`) silently wiping every other key in that
section. The merge is section-level, not shallow.
"""
from __future__ import annotations
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Pure-Python: _overlay_sections
# ---------------------------------------------------------------------------
def test_overlay_preserves_sibling_keys_in_overlapping_section():
"""The exact regression target — local `url` must not wipe `pool_size`."""
from second_brain.config import _overlay_sections
base = {"database": {"url": "", "pool_size": 5}}
local = {"database": {"url": "postgresql://x"}}
out = _overlay_sections(base, local)
assert out["database"]["url"] == "postgresql://x"
assert out["database"]["pool_size"] == 5
def test_overlay_section_only_in_base_survives():
from second_brain.config import _overlay_sections
base = {"database": {"url": "a"}, "scheduler": {"interval": 30}}
local = {"database": {"url": "b"}}
out = _overlay_sections(base, local)
assert out["scheduler"] == {"interval": 30}
assert out["database"]["url"] == "b"
def test_overlay_section_only_in_local_appears():
from second_brain.config import _overlay_sections
base = {"database": {"url": "a"}}
local = {"embeddings": {"ollama_url": "http://x"}}
out = _overlay_sections(base, local)
assert out["database"] == {"url": "a"}
assert out["embeddings"] == {"ollama_url": "http://x"}
def test_overlay_scalar_in_overlay_replaces_dict_in_base():
"""Non-dict overlay values replace outright — no merge attempt."""
from second_brain.config import _overlay_sections
base = {"feature": {"a": 1, "b": 2}}
local = {"feature": "off"} # scalar wins outright
out = _overlay_sections(base, local)
assert out["feature"] == "off"
def test_overlay_dict_in_overlay_replaces_scalar_in_base():
from second_brain.config import _overlay_sections
base = {"feature": "off"}
local = {"feature": {"a": 1}}
out = _overlay_sections(base, local)
assert out["feature"] == {"a": 1}
def test_overlay_empty_overlay_is_identity():
from second_brain.config import _overlay_sections
base = {"database": {"url": "a", "pool_size": 5}}
assert _overlay_sections(base, {}) == base
assert _overlay_sections(base, None) == base
def test_overlay_only_merges_one_level():
"""Doc: the overlay is section-level. Nested dicts inside a section
replace, not deep-merge. This pins the documented depth."""
from second_brain.config import _overlay_sections
base = {"db": {"url": "a", "pool": {"min": 1, "max": 10}}}
local = {"db": {"pool": {"max": 20}}}
out = _overlay_sections(base, local)
# pool replaced wholesale — `min` does NOT survive. This is deliberate.
assert out["db"]["pool"] == {"max": 20}
assert out["db"]["url"] == "a"
# ---------------------------------------------------------------------------
# load_config: end-to-end with real TOML files on disk
# ---------------------------------------------------------------------------
def _write(path: Path, body: str) -> None:
path.write_text(body, encoding="utf-8")
@pytest.fixture(autouse=True)
def _reset_config():
"""Drop the cached singleton before/after each test so overlay state
doesn't leak between cases."""
from second_brain.config import reset_config_singleton
reset_config_singleton()
yield
reset_config_singleton()
def test_load_config_picks_up_sibling_local_overlay(tmp_path):
from second_brain.config import load_config
base = tmp_path / "settings.toml"
local = tmp_path / "settings.local.toml"
_write(
base,
'[database]\nurl = ""\n\n[embeddings]\nollama_url = "http://ollama:11434"\nmodel = "nomic-embed-text"\n',
)
_write(
local,
'[database]\nurl = "postgresql+psycopg://lovebug:x@127.0.0.1:5433/petalbrain"\n\n[embeddings]\nollama_url = "http://127.0.0.1:11434"\n',
)
cfg = load_config(base)
# url from local wins; default model survives (not in local).
assert cfg.database["url"].startswith("postgresql+psycopg://")
assert cfg.embeddings["ollama_url"] == "http://127.0.0.1:11434"
assert cfg.embeddings["model"] == "nomic-embed-text"
def test_load_config_no_local_overlay_is_inert(tmp_path):
from second_brain.config import load_config
base = tmp_path / "settings.toml"
_write(base, '[database]\nurl = "from-base"\n')
cfg = load_config(base)
assert cfg.database["url"] == "from-base"
def test_load_config_local_overlay_at_explicit_path(tmp_path):
"""Doc: when an explicit (or SECOND_BRAIN_CONFIG) path is used, the
overlay sibling is resolved next to THAT path — not next to the
repo's config dir."""
from second_brain.config import load_config
subdir = tmp_path / "etc"
subdir.mkdir()
base = subdir / "settings.toml"
local = subdir / "settings.local.toml"
_write(base, '[database]\nurl = "base"\n')
_write(local, '[database]\nurl = "overlay-wins"\n')
cfg = load_config(base)
assert cfg.database["url"] == "overlay-wins"