返回 last30days-skill
test_save_dir_env_fallback.py
根目录 / tests / test_save_dir_env_fallback.py
1 # ruff: noqa: E402
2 """Tests for `LAST30DAYS_MEMORY_DIR` env-var fallback for `--save-dir`.
3
4 When an agent invokes the engine directly (bypassing the SKILL.md wrapper),
5 the `--save-dir` flag is often omitted. Previously this silently no-op'd the
6 file save while DB persistence still happened (via LAST30DAYS_STORE env var),
7 making the failure invisible. These tests pin the fix: an unset `--save-dir`
8 now defaults to `LAST30DAYS_MEMORY_DIR` from either os.environ or the user's
9 .env file, mirroring the LAST30DAYS_STORE pattern.
10
11 Issue: https://github.com/dzivkovi/last30days-skill/issues/8
12 """
13
14 from __future__ import annotations
15
16 import os
17 import shutil
18 import subprocess
19 import sys
20 import tempfile
21 import unittest
22 from pathlib import Path
23
24 REPO_ROOT = Path(__file__).resolve().parents[1]
25
26
27 def _engine_path() -> Path:
28 return REPO_ROOT / "skills" / "last30days" / "scripts" / "last30days.py"
29
30
31 def _run_engine(
32 topic: str,
33 extra_argv: list[str],
34 env_overrides: dict[str, str],
35 ) -> subprocess.CompletedProcess:
36 cmd = [
37 sys.executable,
38 str(_engine_path()),
39 topic,
40 "--mock",
41 "--emit=md",
42 *extra_argv,
43 ]
44 # Scrub LAST30DAYS_MEMORY_DIR from the inherited parent env so a developer
45 # who has it exported (or in a global .env) doesn't accidentally satisfy
46 # the no-env-no-flag negative test. Each test re-introduces it explicitly
47 # via env_overrides when needed.
48 base = {k: v for k, v in os.environ.items() if k != "LAST30DAYS_MEMORY_DIR"}
49 env = {**base, "LAST30DAYS_SKIP_PREFLIGHT": "1", **env_overrides}
50 return subprocess.run(
51 cmd,
52 capture_output=True,
53 text=True,
54 env=env,
55 encoding="utf-8",
56 errors="replace",
57 check=False,
58 )
59
60
61 class SaveDirEnvFallbackTests(unittest.TestCase):
62 """Pin the fallback contract: env var supplies --save-dir when flag omitted."""
63
64 def setUp(self) -> None:
65 self.tmp = Path(tempfile.mkdtemp(prefix="l30d-savedir-env-"))
66 self.config_dir = self.tmp / "config"
67 self.config_dir.mkdir()
68 self.save_target = self.tmp / "Last30Days"
69 self.save_target.mkdir()
70
71 def tearDown(self) -> None:
72 shutil.rmtree(self.tmp, ignore_errors=True)
73
74 def _write_dotenv(self, contents: str) -> None:
75 (self.config_dir / ".env").write_text(contents, encoding="utf-8")
76
77 def test_env_var_in_dotenv_file_triggers_save(self) -> None:
78 """Setting LAST30DAYS_MEMORY_DIR in .env makes --save-dir-less runs save."""
79 self._write_dotenv(f"LAST30DAYS_MEMORY_DIR={self.save_target}\n")
80 result = _run_engine(
81 topic="OpenAI",
82 extra_argv=[],
83 env_overrides={"LAST30DAYS_CONFIG_DIR": str(self.config_dir)},
84 )
85 self.assertEqual(result.returncode, 0, msg=result.stderr)
86 files = sorted(self.save_target.glob("*.md"))
87 self.assertGreaterEqual(
88 len(files), 1,
89 msg=f"No file in {self.save_target}. stderr: {result.stderr}",
90 )
91
92 def test_shell_exported_env_var_triggers_save(self) -> None:
93 """Setting LAST30DAYS_MEMORY_DIR in os.environ (not in .env) also works."""
94 result = _run_engine(
95 topic="OpenAI",
96 extra_argv=[],
97 env_overrides={
98 "LAST30DAYS_CONFIG_DIR": "",
99 "LAST30DAYS_MEMORY_DIR": str(self.save_target),
100 },
101 )
102 self.assertEqual(result.returncode, 0, msg=result.stderr)
103 files = sorted(self.save_target.glob("*.md"))
104 self.assertGreaterEqual(
105 len(files), 1,
106 msg=f"No file in {self.save_target}. stderr: {result.stderr}",
107 )
108
109 def test_explicit_save_dir_flag_wins_over_env_var(self) -> None:
110 """--save-dir always wins; env var is fallback only."""
111 flag_target = self.tmp / "flag-wins"
112 flag_target.mkdir()
113 self._write_dotenv(f"LAST30DAYS_MEMORY_DIR={self.save_target}\n")
114 result = _run_engine(
115 topic="OpenAI",
116 extra_argv=["--save-dir", str(flag_target)],
117 env_overrides={"LAST30DAYS_CONFIG_DIR": str(self.config_dir)},
118 )
119 self.assertEqual(result.returncode, 0, msg=result.stderr)
120 flag_files = sorted(flag_target.glob("*.md"))
121 env_files = sorted(self.save_target.glob("*.md"))
122 self.assertGreaterEqual(
123 len(flag_files), 1,
124 msg=f"Flag dir empty — flag-over-env precedence broken. stderr: {result.stderr}",
125 )
126 self.assertEqual(
127 len(env_files), 0,
128 msg="Env-var dir got a file when flag was explicit — precedence broken.",
129 )
130
131 def test_no_env_no_flag_preserves_no_save_behavior(self) -> None:
132 """Neither flag nor env var → no file saved, no error (current behavior)."""
133 result = _run_engine(
134 topic="OpenAI",
135 extra_argv=[],
136 env_overrides={"LAST30DAYS_CONFIG_DIR": ""},
137 )
138 self.assertEqual(result.returncode, 0, msg=result.stderr)
139 files = sorted(self.save_target.glob("*.md"))
140 self.assertEqual(len(files), 0)
141
142 def test_empty_string_env_var_does_not_trigger_save(self) -> None:
143 """LAST30DAYS_MEMORY_DIR='' is treated as 'no fallback', not as a path."""
144 result = _run_engine(
145 topic="OpenAI",
146 extra_argv=[],
147 env_overrides={
148 "LAST30DAYS_CONFIG_DIR": "",
149 "LAST30DAYS_MEMORY_DIR": "",
150 },
151 )
152 self.assertEqual(result.returncode, 0, msg=result.stderr)
153 files = sorted(self.save_target.glob("*.md"))
154 self.assertEqual(len(files), 0)
155
156 def test_explicit_empty_save_dir_flag_does_not_trigger_fallback(self) -> None:
157 """--save-dir '' (explicit empty) suppresses save even when env var is set."""
158 result = _run_engine(
159 topic="OpenAI",
160 extra_argv=["--save-dir", ""],
161 env_overrides={
162 "LAST30DAYS_CONFIG_DIR": "",
163 "LAST30DAYS_MEMORY_DIR": str(self.save_target),
164 },
165 )
166 self.assertEqual(result.returncode, 0, msg=result.stderr)
167 files = sorted(self.save_target.glob("*.md"))
168 self.assertEqual(
169 len(files), 0,
170 msg="Explicit empty --save-dir was overridden by env var fallback — `is None` check broken.",
171 )
172
173 def test_shell_empty_env_var_overrides_dotenv_value(self) -> None:
174 """LAST30DAYS_MEMORY_DIR='' in shell suppresses save even when .env has a value.
175
176 Without `is not None` semantics at the env layer, the empty shell export
177 would silently fall through to the .env value (the `or` operator treats
178 '' and None identically). This pins the env-over-config-when-explicit rule.
179 """
180 self._write_dotenv(f"LAST30DAYS_MEMORY_DIR={self.save_target}\n")
181 result = _run_engine(
182 topic="OpenAI",
183 extra_argv=[],
184 env_overrides={
185 "LAST30DAYS_CONFIG_DIR": str(self.config_dir),
186 "LAST30DAYS_MEMORY_DIR": "",
187 },
188 )
189 self.assertEqual(result.returncode, 0, msg=result.stderr)
190 files = sorted(self.save_target.glob("*.md"))
191 self.assertEqual(
192 len(files), 0,
193 msg="Shell-empty env var must suppress save even when .env has a value.",
194 )
195
196 def test_env_var_pointing_to_nonexistent_dir_creates_it(self) -> None:
197 """save_output calls mkdir(parents=True, exist_ok=True); env-var path should too."""
198 deep_target = self.tmp / "does" / "not" / "exist" / "yet"
199 self.assertFalse(deep_target.exists())
200 result = _run_engine(
201 topic="OpenAI",
202 extra_argv=[],
203 env_overrides={
204 "LAST30DAYS_CONFIG_DIR": "",
205 "LAST30DAYS_MEMORY_DIR": str(deep_target),
206 },
207 )
208 self.assertEqual(result.returncode, 0, msg=result.stderr)
209 self.assertTrue(deep_target.exists())
210 files = sorted(deep_target.glob("*.md"))
211 self.assertGreaterEqual(len(files), 1)
212
213
214 if __name__ == "__main__":
215 unittest.main()
216
216 lines PYTHON