Skip to content

Commit 237791e

Browse files
committed
tests: add coverage for deferred audit findings + isolate test_settings from ambient env
Two test-suite hardening changes, all in tests/, no source-code changes: ## 1. Coverage additions for 3 audit-deferred findings (6 test functions) - tests/integration/test_dependencies.py TestLoadAllShortCircuit (2 tests) Guards the ~53ms/populate-key perf fix from PR #1499 by spying on Dependencies.load during a repeat load_all_upstream/load_all_downstream call. Verified: gating the early-return at dependencies.py:266-267, :300-301 with 'if False and ...' makes both tests fail; restored. - tests/integration/test_gc.py TestDeleteSchemaPathPruning (2 tests) Covers the parent-directory pruning loop in gc.py delete_schema_path. Inserts two rows in separate pk-dirs, deletes one, runs collect(), asserts the orphan's pk-dir is pruned, the sibling's is preserved, and the store root is untouched. Verified: removing the pruning loop at gc.py:293-302 makes test_pruning_removes_empty_pk_directory fail; restored. - tests/integration/test_object.py TestStoragePathGeneration (2 tests) Covers build_object_path with schema_prefix='' (the documented pre-2.3.1 legacy layout — not previously tested). Asserts the path is {schema}/{table}/pk=v/field_token.dat (no leading empty segment). Verified: removing the 'if prefix:' guard at storage.py:267-269 makes both tests fail; restored. ## 2. Env-isolation fixture for tests/unit/test_settings.py Four tests in TestStoreEnv and TestBackendConfiguration read DJ_HOST/ DJ_USER/DJ_PASS/DJ_PORT/DJ_BACKEND via pydantic-settings and fail if any of those vars is exported in the developer's shell — even when the test's own contract is 'ignore config file' or 'auto-detect port'. CI runs in a clean env so this only bites devs who source a local .env, but the tests' contract shouldn't depend on ambient env cleanliness. Adds a class-level @pytest.fixture(autouse=True) that monkeypatch.delenv's the 5 DJ_* vars at the top of each test in both classes. Individual tests that need to set DJ_* explicitly still work — the fixture runs first, so their setenv wins. Verified: pytest tests/unit/test_settings.py::TestStoreEnv tests/unit/test_settings.py::TestBackendConfiguration → 19/19 pass locally with the fixture.
1 parent 5f49c76 commit 237791e

4 files changed

Lines changed: 250 additions & 0 deletions

File tree

tests/integration/test_dependencies.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import patch
2+
13
from pytest import raises
24

35
from datajoint import errors
@@ -50,3 +52,62 @@ def test_unique_dependency(thing_tables):
5052
# duplicate foreign key attributes = not ok
5153
with raises(errors.DuplicateError):
5254
c.insert1(dict(a=1, b1=1, b2=1))
55+
56+
57+
class TestLoadAllShortCircuit:
58+
"""Finding #7 (audit deferred): the ``load_all_upstream`` /
59+
``load_all_downstream`` helpers must short-circuit when the graph already
60+
covers every schema they would discover. The guard at
61+
``dependencies.py:266-267`` and ``:300-301`` was introduced by PR #1499 as
62+
the ~53 ms/key populate speedup; a regression would silently pass CI
63+
because the correctness of the graph is unaffected — only its cost.
64+
65+
These tests count calls to ``Dependencies.load`` on the second invocation.
66+
The first call must load once (to populate ``_loaded_schemas``); the
67+
second must NOT call ``load`` at all — the ``if self._loaded and
68+
known_schemas <= self._loaded_schemas: return`` early-return owns the
69+
entire path.
70+
"""
71+
72+
def test_load_all_downstream_short_circuits_on_repeat(self, thing_tables):
73+
# thing_tables ensures at least one schema is activated on the
74+
# connection, so the ``if not known_schemas: self.load(); return``
75+
# bail-out branch does not fire.
76+
a, _, _, _, _ = thing_tables
77+
conn = a.connection
78+
79+
# Warm up the graph once — this call is allowed to invoke load().
80+
conn.dependencies.load_all_downstream()
81+
assert conn.dependencies._loaded is True
82+
assert conn.dependencies._loaded_schemas, "warm-up must populate _loaded_schemas"
83+
84+
# Second identical call must hit the early-return: no load(), no rebuild.
85+
with patch.object(
86+
type(conn.dependencies), "load", wraps=conn.dependencies.load
87+
) as spy_load:
88+
conn.dependencies.load_all_downstream()
89+
assert spy_load.call_count == 0, (
90+
"load_all_downstream must short-circuit when _loaded_schemas already "
91+
"covers the discovered set (#1493, PR #1499). Deleting the "
92+
"`if self._loaded and known_schemas <= self._loaded_schemas: return` "
93+
"at dependencies.py:266-267 would make this test fail."
94+
)
95+
96+
def test_load_all_upstream_short_circuits_on_repeat(self, thing_tables):
97+
a, _, _, _, _ = thing_tables
98+
conn = a.connection
99+
100+
conn.dependencies.load_all_upstream()
101+
assert conn.dependencies._loaded is True
102+
assert conn.dependencies._loaded_schemas
103+
104+
with patch.object(
105+
type(conn.dependencies), "load", wraps=conn.dependencies.load
106+
) as spy_load:
107+
conn.dependencies.load_all_upstream()
108+
assert spy_load.call_count == 0, (
109+
"load_all_upstream must short-circuit when _loaded_schemas already "
110+
"covers the discovered set (#1493, PR #1499). Deleting the "
111+
"`if self._loaded and known_schemas <= self._loaded_schemas: return` "
112+
"at dependencies.py:300-301 would make this test fail."
113+
)

tests/integration/test_gc.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,3 +843,116 @@ def test_scan_errors_in_stats_dict(self):
843843
assert len(stats["scan_errors"]) == 1
844844
assert stats["scan_errors"][0].startswith("list_hash_paths(s):")
845845
assert "s3 access denied" in stats["scan_errors"][0]
846+
847+
848+
class TestDeleteSchemaPathPruning:
849+
"""Finding #8 (audit deferred): ``GarbageCollector.delete_schema_path``
850+
prunes now-empty parent directories after removing the orphaned file
851+
(``gc.py:277-306``). The pruning walk was previously uncovered — no test
852+
exercised the ``while parent and parent != root and parent.startswith(root)``
853+
loop, its ``fs.ls`` "not empty" break, its exception guards, or the
854+
stop-at-store-root boundary.
855+
856+
A regression to "delete the file, leave every parent dir behind" would
857+
silently pass CI and gradually accumulate empty directories on every
858+
store, so a live-data test that inspects the on-disk tree after
859+
``collect(dry_run=False)`` is the right lock.
860+
"""
861+
862+
@pytest.fixture
863+
def schema_prune(self, connection_test, prefix, mock_stores):
864+
schema = dj.Schema(
865+
f"{prefix}_test_gc_prune",
866+
context={"GcObjectTest": GcObjectTest},
867+
connection=connection_test,
868+
)
869+
schema(GcObjectTest)
870+
yield schema
871+
schema.drop()
872+
873+
def test_pruning_removes_empty_pk_directory(self, schema_prune):
874+
"""Deleting the only file under a pk-dir must remove the file AND
875+
prune the now-empty pk-dir; a sibling pk-dir with a live file survives.
876+
877+
Regressing ``delete_schema_path`` to skip the ``while ... rmdir``
878+
pruning loop would leave the empty ``rid=1/`` directory behind and
879+
make this test fail.
880+
"""
881+
from pathlib import Path
882+
883+
# Two rows in separate pk-dirs: rid=1 will be orphaned, rid=2 stays live.
884+
GcObjectTest.insert1({"rid": 1, "results": b"orphan-me"})
885+
GcObjectTest.insert1({"rid": 2, "results": b"live"})
886+
887+
collector = _gc(schema_prune)
888+
cfg = schema_prune.connection._config
889+
store_root = Path(cfg.get_store_spec("local")["location"])
890+
891+
# Sanity: both pk-dirs exist on disk pre-delete.
892+
refs_all = collector.schema_references()
893+
assert len(refs_all) == 2
894+
for ref in refs_all:
895+
assert (store_root / ref).exists(), f"expected {ref} to exist pre-delete"
896+
897+
# Orphan rid=1 and reclaim.
898+
(GcObjectTest & {"rid": 1}).delete(prompt=False)
899+
refs_live = collector.schema_references()
900+
live_ref = next(iter(refs_live))
901+
dead_ref = next(iter(refs_all - refs_live))
902+
903+
stats = collector.collect(dry_run=False)
904+
assert stats["schema_paths_deleted"] >= 1
905+
906+
# The orphaned file is gone.
907+
assert not (store_root / dead_ref).exists(), (
908+
f"orphaned schema file must be deleted; still present at {dead_ref}"
909+
)
910+
# The pruning loop must have removed the now-empty pk-dir.
911+
dead_pk_dir = (store_root / dead_ref).parent
912+
assert not dead_pk_dir.exists(), (
913+
f"parent pk-directory must be pruned when its last file is removed; "
914+
f"still present at {dead_pk_dir} — deleting the pruning loop at "
915+
f"gc.py:293-302 would make this test fail"
916+
)
917+
918+
# The sibling pk-dir with a live file must be preserved.
919+
live_pk_dir = (store_root / live_ref).parent
920+
assert live_pk_dir.exists(), (
921+
f"pk-directory holding a live file must not be pruned; missing {live_pk_dir}"
922+
)
923+
assert (store_root / live_ref).exists(), (
924+
f"live schema file must survive collect(); missing {live_ref}"
925+
)
926+
927+
# The table directory (grandparent) still holds live content, so it
928+
# must be preserved — the pruning walk must stop at "not empty".
929+
assert live_pk_dir.parent.exists(), (
930+
"table directory must survive when it still holds a live pk-dir"
931+
)
932+
933+
def test_pruning_stops_at_store_root(self, schema_prune):
934+
"""After removing the last orphan under a schema, the pruning walk may
935+
remove the pk-dir, table-dir, schema-dir, and section-dir but MUST
936+
stop at the store root — a regression to "keep walking past the root"
937+
would be catastrophic (removes user's storage directory itself).
938+
939+
The boundary ``while parent and parent != root and parent.startswith(root)``
940+
owns this invariant.
941+
"""
942+
from pathlib import Path
943+
944+
GcObjectTest.insert1({"rid": 1, "results": b"only-row"})
945+
946+
collector = _gc(schema_prune)
947+
cfg = schema_prune.connection._config
948+
store_root = Path(cfg.get_store_spec("local")["location"])
949+
assert store_root.exists()
950+
951+
(GcObjectTest & {"rid": 1}).delete(prompt=False)
952+
collector.collect(dry_run=False)
953+
954+
# Store root must survive even when its entire contents were pruned.
955+
assert store_root.exists(), (
956+
f"pruning walk must stop at store root; root itself was removed at {store_root} — "
957+
f"the `parent != root and parent.startswith(root)` boundary in gc.py:295 owns this"
958+
)

tests/integration/test_object.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,56 @@ def test_build_object_path_with_partition(self):
130130
# section prefix first, then partition attrs (schema_prefix default "_schema")
131131
assert path.startswith("_schema/subject_id=1")
132132

133+
def test_build_object_path_empty_schema_prefix(self):
134+
"""Finding #19 (audit deferred): ``schema_prefix=""`` reproduces the
135+
documented legacy pre-2.3.1 storage layout (no section prefix).
136+
137+
The guard at ``storage.py:267-269`` (``prefix = schema_prefix.strip("/");
138+
if prefix: parts.append(prefix)``) exists specifically so an empty
139+
prefix does NOT produce a path with an empty leading segment (e.g.
140+
``"/myschema/MyTable/..."``). Every other test in this class passes
141+
``schema_prefix="_schema"`` and would still pass if the ``if prefix:``
142+
guard were deleted, so a regression to always-prepend would go
143+
unnoticed. This test locks in the pre-2.3.1 layout.
144+
"""
145+
path, token = build_object_path(
146+
schema="myschema",
147+
table="MyTable",
148+
field="data_file",
149+
primary_key={"id": 42},
150+
ext=".dat",
151+
schema_prefix="",
152+
)
153+
assert path == f"myschema/MyTable/id=42/data_file_{token}.dat", (
154+
f"empty schema_prefix must produce a path with NO leading section "
155+
f"segment (documented legacy layout); got {path!r}"
156+
)
157+
# Regression sentinel: deleting the ``if prefix:`` guard would produce
158+
# a leading empty segment, i.e. ``"/myschema/..."``.
159+
assert not path.startswith("/"), (
160+
f"path must not have a leading empty segment when schema_prefix is empty; "
161+
f"got {path!r}"
162+
)
163+
164+
def test_build_object_path_empty_schema_prefix_with_partition(self):
165+
"""Finding #19 companion: legacy layout must also compose correctly
166+
with a partition pattern — partition attrs lead, no empty segment
167+
before them."""
168+
path, token = build_object_path(
169+
schema="myschema",
170+
table="MyTable",
171+
field="data",
172+
primary_key={"subject_id": 1, "session_id": 2},
173+
ext=".dat",
174+
partition_pattern="{subject_id}",
175+
schema_prefix="",
176+
)
177+
assert path.startswith("subject_id=1/myschema/"), (
178+
f"empty schema_prefix + partition must start with the partition "
179+
f"segment (no empty leading segment); got {path!r}"
180+
)
181+
assert not path.startswith("/")
182+
133183

134184
class TestObjectRef:
135185
"""Tests for ObjectRef class."""

tests/unit/test_settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,19 @@ def test_load_store_arbitrary_attr(self, tmp_path):
509509
class TestStoreEnv:
510510
"""Test DJ_STORES env var and DJ_IGNORE_CONFIG_FILE flag."""
511511

512+
@pytest.fixture(autouse=True)
513+
def _isolate_dj_env(self, monkeypatch):
514+
"""Clear ambient DJ_* env vars so tests are isolated from the developer's shell.
515+
516+
pydantic-settings reads any DJ_* prefixed variable and merges it into the
517+
settings object; a developer with ``DJ_HOST``/``DJ_USER``/``DJ_PASS``/
518+
``DJ_PORT``/``DJ_BACKEND`` exported (e.g. from a local ``.env``) would
519+
otherwise see assertions on the config's defaults or file-loaded values
520+
fail here even though the test's own contract is unrelated.
521+
"""
522+
for var in ("DJ_HOST", "DJ_USER", "DJ_PASS", "DJ_PORT", "DJ_BACKEND"):
523+
monkeypatch.delenv(var, raising=False)
524+
512525
def _isolate_filesystem(self, monkeypatch, tmp_path):
513526
"""chdir into a tmp_path with a .git sentinel so find_config_file stops there."""
514527
(tmp_path / ".git").mkdir()
@@ -937,6 +950,19 @@ def test_database_prefix_empty_no_warning(self):
937950
class TestBackendConfiguration:
938951
"""Test database backend configuration and port auto-detection."""
939952

953+
@pytest.fixture(autouse=True)
954+
def _isolate_dj_env(self, monkeypatch):
955+
"""Clear ambient DJ_* env vars so tests are isolated from the developer's shell.
956+
957+
``DatabaseSettings()`` reads ``DJ_HOST``/``DJ_USER``/``DJ_PASS``/
958+
``DJ_PORT``/``DJ_BACKEND`` from the process env; leaked values would
959+
otherwise fail assertions on backend defaults and auto-detected ports.
960+
Individual tests may still ``monkeypatch.setenv(...)`` to test explicit
961+
settings — the fixture runs first, so their setenv wins.
962+
"""
963+
for var in ("DJ_HOST", "DJ_USER", "DJ_PASS", "DJ_PORT", "DJ_BACKEND"):
964+
monkeypatch.delenv(var, raising=False)
965+
940966
def test_backend_default(self):
941967
"""Test default backend is mysql."""
942968
from datajoint.settings import DatabaseSettings

0 commit comments

Comments
 (0)