返回 last30days-skill
test_setup_wizard.py
根目录 / tests / test_setup_wizard.py
1 """Tests for the first-run setup wizard module."""
2
3 import os
4 import subprocess
5 import tempfile
6 from pathlib import Path
7 from unittest.mock import patch, MagicMock
8
9 import pytest
10
11 from lib import setup_wizard
12
13
14 class _NtOs:
15 """Delegates to the real os module but reports name == 'nt'.
16
17 Patched only in setup_wizard's own namespace (mirrors
18 tests/test_health_probe_taxonomy.py's ``_NtOs``) so pathlib and the rest
19 of the test process are unaffected.
20 """
21 name = "nt"
22
23 def __getattr__(self, attr):
24 return getattr(os, attr)
25
26
27 class _PosixOs:
28 """Delegates to the real os module but reports name == 'posix'."""
29 name = "posix"
30
31 def __getattr__(self, attr):
32 return getattr(os, attr)
33
34
35 class TestIsFirstRun:
36 """Tests for is_first_run()."""
37
38 def test_first_run_when_setup_complete_not_set(self):
39 """SETUP_COMPLETE not in config -> first run."""
40 config = {"AUTH_TOKEN": "abc", "CT0": "xyz"}
41 assert setup_wizard.is_first_run(config) is True
42
43 def test_first_run_when_setup_complete_is_none(self):
44 """SETUP_COMPLETE=None -> first run."""
45 config = {"SETUP_COMPLETE": None}
46 assert setup_wizard.is_first_run(config) is True
47
48 def test_first_run_when_setup_complete_is_empty(self):
49 """SETUP_COMPLETE="" -> first run."""
50 config = {"SETUP_COMPLETE": ""}
51 assert setup_wizard.is_first_run(config) is True
52
53 def test_not_first_run_when_setup_complete_true(self):
54 """SETUP_COMPLETE=true -> not first run."""
55 config = {"SETUP_COMPLETE": "true"}
56 assert setup_wizard.is_first_run(config) is False
57
58 def test_not_first_run_when_setup_complete_any_value(self):
59 """SETUP_COMPLETE set to any truthy value -> not first run."""
60 config = {"SETUP_COMPLETE": "yes"}
61 assert setup_wizard.is_first_run(config) is False
62
63
64 class TestRunAutoSetup:
65 """Tests for run_auto_setup()."""
66
67 @patch("lib.cookie_extract.extract_cookies_with_source")
68 @patch("shutil.which")
69 def test_cookies_found(self, mock_which, mock_extract):
70 """When cookies are found, results dict includes them."""
71 mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
72 mock_which.return_value = "/usr/local/bin/yt-dlp"
73
74 config = {}
75 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
76
77 assert "x" in results["cookies_found"]
78 assert results["cookies_found"]["x"] == "chrome"
79 assert results["ytdlp_installed"] is True
80 assert results["ytdlp_action"] == "already_installed"
81 assert results["env_written"] is False
82
83 @patch("lib.cookie_extract.extract_cookies_with_source")
84 @patch("shutil.which")
85 def test_no_cookies_found(self, mock_which, mock_extract, monkeypatch):
86 """When no cookies found, results dict has empty cookies_found."""
87 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
88 mock_extract.return_value = None
89 mock_which.return_value = None
90
91 config = {}
92 results = setup_wizard.run_auto_setup(config)
93
94 assert results["cookies_found"] == {}
95 mock_extract.assert_not_called()
96 assert results["ytdlp_installed"] is False
97 assert results["ytdlp_action"] == "no_homebrew"
98
99 @patch("lib.cookie_extract.extract_cookies_with_source")
100 @patch("shutil.which")
101 def test_cookie_extraction_exception(self, mock_which, mock_extract):
102 """Cookie extraction raising an exception is handled gracefully."""
103 mock_extract.side_effect = Exception("DB locked")
104 mock_which.return_value = None
105
106 config = {}
107 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
108
109 assert results["cookies_found"] == {}
110
111 @patch("lib.cookie_extract.extract_cookies_with_source")
112 @patch("shutil.which")
113 def test_multiple_sources(self, mock_which, mock_extract):
114 """Multiple cookie sources can be found."""
115 def side_effect(browser, domain, cookie_names):
116 if domain == ".x.com":
117 return ({"auth_token": "abc", "ct0": "xyz"}, "firefox")
118 elif domain == ".truthsocial.com":
119 return ({"_session_id": "sess123"}, "firefox")
120 return None
121
122 mock_extract.side_effect = side_effect
123 mock_which.return_value = None
124
125 config = {}
126 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
127
128 assert results["cookies_found"]["x"] == "firefox"
129 assert results["cookies_found"]["truthsocial"] == "firefox"
130
131 @patch("lib.cookie_extract.extract_cookies_with_source")
132 @patch("shutil.which")
133 def test_default_scan_order_is_chromium_first(self, mock_which, mock_extract):
134 """U1: with FROM_BROWSER unset, the scan tries Chrome before Safari.
135
136 Safari is the only X-cookie source needing Full Disk Access; Chrome
137 reads via the Keychain with no FDA. The wizard must try the Chromium
138 family first so the common macOS user does not hit the FDA dead-end.
139 """
140 tried = []
141
142 def side_effect(browser, domain, cookie_names):
143 tried.append(browser)
144 return None # never find cookies, so every browser is attempted
145
146 mock_extract.side_effect = side_effect
147 mock_which.return_value = None
148
149 setup_wizard.run_auto_setup({}, allow_browser_cookies=True)
150
151 # Order within the first domain's attempts must be chromium-first.
152 assert "chrome" in tried and "safari" in tried
153 assert tried.index("chrome") < tried.index("safari")
154 assert tried.index("chrome") < tried.index("firefox")
155 # And it must not route through the silent-first "auto" order.
156 assert tried[0] == "chrome"
157
158
159 class TestYtdlpAutoInstall:
160 """Tests for yt-dlp auto-install via Homebrew in run_auto_setup()."""
161
162 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
163 @patch("subprocess.run")
164 @patch("shutil.which")
165 def test_ytdlp_missing_brew_available_installs(self, mock_which, mock_subproc, mock_extract, monkeypatch):
166 """yt-dlp missing + brew available (non-Windows) -> installs via brew."""
167 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
168 def which_side_effect(cmd):
169 if cmd == "yt-dlp":
170 return None
171 if cmd == "brew":
172 return "/opt/homebrew/bin/brew"
173 return None
174 mock_which.side_effect = which_side_effect
175 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
176
177 results = setup_wizard.run_auto_setup({})
178
179 mock_subproc.assert_called_once_with(
180 ["brew", "install", "yt-dlp"],
181 capture_output=True, text=True, timeout=120,
182 )
183 assert results["ytdlp_installed"] is True
184 assert results["ytdlp_action"] == "installed"
185
186 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
187 @patch("shutil.which")
188 def test_ytdlp_missing_brew_missing(self, mock_which, mock_extract, monkeypatch):
189 """yt-dlp missing + brew missing (non-Windows) -> no_homebrew."""
190 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
191 mock_which.return_value = None
192
193 results = setup_wizard.run_auto_setup({})
194
195 assert results["ytdlp_installed"] is False
196 assert results["ytdlp_action"] == "no_homebrew"
197
198 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
199 @patch("shutil.which")
200 def test_ytdlp_missing_on_windows(self, mock_which, mock_extract, monkeypatch):
201 """Regression for #904: yt-dlp missing on Windows -> pip guidance, no
202 Homebrew attempt (Windows has no Homebrew and pip is the working path)."""
203 monkeypatch.setattr(setup_wizard, "os", _NtOs())
204 mock_which.return_value = None
205
206 with patch("subprocess.run") as mock_subproc:
207 results = setup_wizard.run_auto_setup({})
208 mock_subproc.assert_not_called()
209
210 assert results["ytdlp_installed"] is False
211 assert results["ytdlp_action"] == "no_pip_windows"
212
213 text = setup_wizard.get_setup_status_text(results)
214 assert "pip install yt-dlp" in text
215 assert "Homebrew" not in text
216 assert "Scripts" in text
217
218 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
219 @patch("shutil.which")
220 def test_ytdlp_already_installed(self, mock_which, mock_extract):
221 """yt-dlp already installed -> already_installed."""
222 mock_which.return_value = "/usr/local/bin/yt-dlp"
223
224 results = setup_wizard.run_auto_setup({})
225
226 assert results["ytdlp_installed"] is True
227 assert results["ytdlp_action"] == "already_installed"
228
229 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
230 @patch("subprocess.run")
231 @patch("shutil.which")
232 def test_brew_install_fails(self, mock_which, mock_subproc, mock_extract, monkeypatch):
233 """brew install yt-dlp fails (non-Windows) -> install_failed with stderr."""
234 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
235 def which_side_effect(cmd):
236 if cmd == "yt-dlp":
237 return None
238 if cmd == "brew":
239 return "/opt/homebrew/bin/brew"
240 return None
241 mock_which.side_effect = which_side_effect
242 mock_subproc.return_value = MagicMock(returncode=1, stderr="Error: something broke")
243
244 results = setup_wizard.run_auto_setup({})
245
246 assert results["ytdlp_installed"] is False
247 assert results["ytdlp_action"] == "install_failed"
248 assert "something broke" in results["ytdlp_stderr"]
249
250
251 class TestDiggAutoInstall:
252 """Tests for digg-pp-cli auto-install via npx in run_auto_setup()."""
253
254 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
255 @patch("shutil.which")
256 def test_digg_already_installed(self, mock_which, mock_extract):
257 """digg-pp-cli already on PATH -> already_installed, no subprocess."""
258 # yt-dlp missing + brew missing keeps the yt-dlp path subprocess-free;
259 # digg-pp-cli present short-circuits before any npx call.
260 def which_side_effect(cmd):
261 return "/Users/me/go/bin/digg-pp-cli" if cmd == "digg-pp-cli" else None
262 mock_which.side_effect = which_side_effect
263
264 with patch("subprocess.run") as mock_subproc:
265 results = setup_wizard.run_auto_setup({})
266 mock_subproc.assert_not_called()
267
268 assert results["digg_installed"] is True
269 assert results["digg_action"] == "already_installed"
270
271 # Redirect HOME/GOPATH so real ~/.local/bin or ~/go/bin digg-pp-cli on the
272 # dev box does not make the binary look present during absence tests.
273 @staticmethod
274 def _empty_home(tmp_path, monkeypatch):
275 monkeypatch.delenv("GOPATH", raising=False)
276 monkeypatch.setenv("HOME", str(tmp_path))
277
278 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
279 @patch("shutil.which")
280 def test_digg_no_npx(self, mock_which, mock_extract, tmp_path, monkeypatch):
281 """digg-pp-cli missing + npx missing -> no_npx, no subprocess."""
282 self._empty_home(tmp_path, monkeypatch)
283 mock_which.return_value = None
284
285 with patch("subprocess.run") as mock_subproc:
286 results = setup_wizard.run_auto_setup({})
287 mock_subproc.assert_not_called()
288
289 assert results["digg_installed"] is False
290 assert results["digg_action"] == "no_npx"
291
292 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
293 @patch("subprocess.run")
294 @patch("shutil.which")
295 def test_digg_install_succeeds(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
296 """npx present + install succeeds + binary verifiable -> installed."""
297 self._empty_home(tmp_path, monkeypatch)
298 # First which("digg-pp-cli") (pre-install) -> None, npx -> present,
299 # then post-install which("digg-pp-cli") -> resolves.
300 calls = {"digg": 0}
301
302 def which_side_effect(cmd):
303 if cmd == "digg-pp-cli":
304 calls["digg"] += 1
305 return None if calls["digg"] == 1 else "/Users/me/go/bin/digg-pp-cli"
306 if cmd == "npx":
307 return "/opt/homebrew/bin/npx"
308 return None
309 mock_which.side_effect = which_side_effect
310 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
311
312 results = setup_wizard.run_auto_setup({})
313
314 # The wizard now also best-effort-installs the additional default-on
315 # Printing Press sources (arxiv/techmeme/trustpilot), so digg is one of
316 # several install calls rather than the only one. Argv[0] must be the
317 # *resolved* npx path (mirroring shutil.which's return value), not the
318 # bare "npx" string -- passing the bare name breaks Windows, where
319 # shutil.which resolves PATHEXT (npx.CMD) but subprocess.run does not.
320 mock_subproc.assert_any_call(
321 ["/opt/homebrew/bin/npx", "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
322 capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
323 )
324 assert results["digg_installed"] is True
325 assert results["digg_action"] == "installed"
326
327 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
328 @patch("subprocess.run")
329 @patch("shutil.which")
330 def test_digg_install_uses_resolved_windows_npx_path(
331 self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch
332 ):
333 """Regression for #904: a Windows-style resolved npx path (PATHEXT
334 resolution, e.g. npx.CMD) must be passed to subprocess.run verbatim --
335 not the bare string "npx", which fails with WinError 2 on Windows
336 because CreateProcess does not do PATHEXT resolution the way
337 shutil.which does."""
338 self._empty_home(tmp_path, monkeypatch)
339 calls = {"digg": 0}
340 windows_npx = r"C:\Program Files\nodejs\npx.CMD"
341
342 def which_side_effect(cmd):
343 if cmd == "digg-pp-cli":
344 calls["digg"] += 1
345 return None if calls["digg"] == 1 else r"C:\Users\me\.local\bin\digg-pp-cli"
346 if cmd == "npx":
347 return windows_npx
348 return None
349 mock_which.side_effect = which_side_effect
350 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
351
352 results = setup_wizard.run_auto_setup({})
353
354 mock_subproc.assert_any_call(
355 [windows_npx, "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
356 capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
357 )
358 assert results["digg_installed"] is True
359 assert results["digg_action"] == "installed"
360
361 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
362 @patch("subprocess.run")
363 @patch("shutil.which")
364 def test_digg_install_fails_nonzero(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
365 """npx install returns non-zero -> install_failed with stderr."""
366 self._empty_home(tmp_path, monkeypatch)
367 def which_side_effect(cmd):
368 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
369 mock_which.side_effect = which_side_effect
370 mock_subproc.return_value = MagicMock(returncode=1, stderr="npm ERR! boom")
371
372 results = setup_wizard.run_auto_setup({})
373
374 assert results["digg_installed"] is False
375 assert results["digg_action"] == "install_failed"
376 assert "boom" in results["digg_stderr"]
377
378 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
379 @patch("shutil.which")
380 def test_digg_prior_install_off_path(self, mock_which, mock_extract, tmp_path, monkeypatch):
381 """pp-digg CLI at ~/.local/bin but not on PATH -> installed_off_path, no npx."""
382 self._empty_home(tmp_path, monkeypatch)
383 local_bin = tmp_path / ".local" / "bin"
384 local_bin.mkdir(parents=True)
385 binary = local_bin / "digg-pp-cli"
386 binary.write_text("#!/bin/sh\n")
387 binary.chmod(0o755)
388 mock_which.return_value = None
389
390 with patch("subprocess.run") as mock_subproc:
391 results = setup_wizard.run_auto_setup({})
392 mock_subproc.assert_not_called()
393
394 assert results["digg_installed"] is False
395 assert results["digg_action"] == "installed_off_path"
396 assert results["digg_path"] == str(binary)
397
398 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
399 @patch("subprocess.run")
400 @patch("shutil.which")
401 def test_digg_install_zero_but_not_on_path(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
402 """rc=0, binary at $HOME/.local/bin but not on PATH -> installed_off_path."""
403 self._empty_home(tmp_path, monkeypatch)
404 def which_side_effect(cmd):
405 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
406 mock_which.side_effect = which_side_effect
407
408 local_bin = tmp_path / ".local" / "bin"
409
410 def fake_install(*args, **kwargs):
411 local_bin.mkdir(parents=True, exist_ok=True)
412 binary = local_bin / "digg-pp-cli"
413 binary.write_text("#!/bin/sh\n")
414 binary.chmod(0o755)
415 return MagicMock(returncode=0, stderr="")
416 mock_subproc.side_effect = fake_install
417
418 results = setup_wizard.run_auto_setup({})
419
420 assert results["digg_installed"] is False
421 assert results["digg_action"] == "installed_off_path"
422 assert results["digg_path"] == str(local_bin / "digg-pp-cli")
423
424 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
425 @patch("subprocess.run")
426 @patch("shutil.which")
427 def test_digg_install_timeout_does_not_raise(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
428 """subprocess raising (e.g. timeout) -> install_failed, no exception escapes."""
429 self._empty_home(tmp_path, monkeypatch)
430 def which_side_effect(cmd):
431 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
432 mock_which.side_effect = which_side_effect
433 mock_subproc.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=300)
434
435 results = setup_wizard.run_auto_setup({})
436
437 assert results["digg_installed"] is False
438 assert results["digg_action"] == "install_failed"
439
440
441 class TestWriteSetupConfig:
442 """Tests for write_setup_config()."""
443
444 def test_creates_new_env_file(self):
445 """Creates .env with SETUP_COMPLETE; omits FROM_BROWSER when unspecified.
446
447 With no detected browser we must NOT pin FROM_BROWSER=auto, because
448 that makes every later run probe Chrome and re-trigger the macOS
449 Keychain prompt. Leaving it unset applies the safe Firefox/Safari
450 default instead.
451 """
452 with tempfile.TemporaryDirectory() as tmpdir:
453 env_path = Path(tmpdir) / "subdir" / ".env"
454
455 result = setup_wizard.write_setup_config(env_path)
456
457 assert result is True
458 assert env_path.exists()
459 content = env_path.read_text()
460 assert "SETUP_COMPLETE=true" in content
461 assert "FROM_BROWSER" not in content
462
463 def test_appends_to_existing_file(self):
464 """Appends to existing .env without overwriting keys."""
465 with tempfile.TemporaryDirectory() as tmpdir:
466 env_path = Path(tmpdir) / ".env"
467 env_path.write_text("XAI_API_KEY=my-key\nAUTH_TOKEN=tok123\n")
468
469 result = setup_wizard.write_setup_config(env_path)
470
471 assert result is True
472 content = env_path.read_text()
473 # Original keys preserved
474 assert "XAI_API_KEY=my-key" in content
475 assert "AUTH_TOKEN=tok123" in content
476 # SETUP_COMPLETE appended; FROM_BROWSER omitted (no browser detected)
477 assert "SETUP_COMPLETE=true" in content
478 assert "FROM_BROWSER" not in content
479
480 def test_does_not_overwrite_existing_keys(self):
481 """If SETUP_COMPLETE or FROM_BROWSER already exist, don't duplicate."""
482 with tempfile.TemporaryDirectory() as tmpdir:
483 env_path = Path(tmpdir) / ".env"
484 env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
485
486 result = setup_wizard.write_setup_config(env_path)
487
488 assert result is True
489 content = env_path.read_text()
490 # Should only appear once
491 assert content.count("SETUP_COMPLETE") == 1
492 assert content.count("FROM_BROWSER") == 1
493 # Original value preserved
494 assert "FROM_BROWSER=firefox" in content
495
496 def test_custom_from_browser_value(self):
497 """Custom from_browser value is written."""
498 with tempfile.TemporaryDirectory() as tmpdir:
499 env_path = Path(tmpdir) / ".env"
500
501 result = setup_wizard.write_setup_config(env_path, from_browser="chrome")
502
503 assert result is True
504 content = env_path.read_text()
505 assert "FROM_BROWSER=chrome" in content
506
507 def test_creates_parent_directories(self):
508 """Creates parent directories if they don't exist."""
509 with tempfile.TemporaryDirectory() as tmpdir:
510 env_path = Path(tmpdir) / "a" / "b" / "c" / ".env"
511
512 result = setup_wizard.write_setup_config(env_path)
513
514 assert result is True
515 assert env_path.exists()
516
517 def test_handles_file_without_trailing_newline(self):
518 """Appends correctly when existing file has no trailing newline."""
519 with tempfile.TemporaryDirectory() as tmpdir:
520 env_path = Path(tmpdir) / ".env"
521 env_path.write_text("EXISTING_KEY=value") # no trailing newline
522
523 result = setup_wizard.write_setup_config(env_path, from_browser="firefox")
524
525 assert result is True
526 content = env_path.read_text()
527 # Should have newline separator
528 lines = content.strip().split("\n")
529 assert len(lines) == 3
530 assert lines[0] == "EXISTING_KEY=value"
531 assert "SETUP_COMPLETE=true" in lines[1]
532
533
534 class TestWriteApiKey:
535 """Tests for write_api_key() — persisting the ScrapeCreators signup key."""
536
537 def test_writes_key_with_secret_permissions(self):
538 """Key is written and the file is 0o600 (owner read/write only)."""
539 with tempfile.TemporaryDirectory() as tmpdir:
540 env_path = Path(tmpdir) / "subdir" / ".env"
541
542 result = setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
543
544 assert result is True
545 assert env_path.exists()
546 assert "SCRAPECREATORS_API_KEY=sc_live_abcdef123456" in env_path.read_text()
547 assert (env_path.stat().st_mode & 0o777) == 0o600
548
549 def test_value_round_trips_through_env_loader(self):
550 """Persisted key reloads to the exact original value."""
551 from lib import env as env_mod
552 with tempfile.TemporaryDirectory() as tmpdir:
553 env_path = Path(tmpdir) / ".env"
554
555 setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
556
557 loaded = env_mod.load_env_file(env_path)
558 assert loaded["SCRAPECREATORS_API_KEY"] == "sc_live_abcdef123456"
559
560 def test_idempotent_when_key_already_present(self):
561 """If the key already exists, do not duplicate or overwrite it."""
562 with tempfile.TemporaryDirectory() as tmpdir:
563 env_path = Path(tmpdir) / ".env"
564 env_path.write_text("SCRAPECREATORS_API_KEY=existing_key\n")
565
566 result = setup_wizard.write_api_key(env_path, "sc_new_value")
567
568 assert result is True
569 content = env_path.read_text()
570 assert content.count("SCRAPECREATORS_API_KEY") == 1
571 assert "existing_key" in content
572 assert "sc_new_value" not in content
573
574 def test_appends_without_clobbering_other_keys(self):
575 """Existing unrelated keys are preserved."""
576 with tempfile.TemporaryDirectory() as tmpdir:
577 env_path = Path(tmpdir) / ".env"
578 env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
579
580 setup_wizard.write_api_key(env_path, "sc_key_xyz")
581
582 content = env_path.read_text()
583 assert "SETUP_COMPLETE=true" in content
584 assert "FROM_BROWSER=firefox" in content
585 assert "SCRAPECREATORS_API_KEY=sc_key_xyz" in content
586
587 def test_value_with_whitespace_is_quoted(self):
588 """A pathological value with whitespace is quoted so it round-trips."""
589 from lib import env as env_mod
590 with tempfile.TemporaryDirectory() as tmpdir:
591 env_path = Path(tmpdir) / ".env"
592
593 setup_wizard.write_api_key(env_path, "key with space")
594
595 content = env_path.read_text()
596 assert 'SCRAPECREATORS_API_KEY="key with space"' in content
597 assert env_mod.load_env_file(env_path)["SCRAPECREATORS_API_KEY"] == "key with space"
598
599 def test_empty_key_returns_false_and_writes_nothing(self):
600 """An empty api_key persists nothing and reports failure."""
601 with tempfile.TemporaryDirectory() as tmpdir:
602 env_path = Path(tmpdir) / ".env"
603
604 assert setup_wizard.write_api_key(env_path, "") is False
605 assert not env_path.exists()
606
607 def test_unwritable_target_returns_false(self):
608 """Unwritable target dir -> False, no exception escapes."""
609 with tempfile.TemporaryDirectory() as tmpdir:
610 ro_dir = Path(tmpdir) / "ro"
611 ro_dir.mkdir()
612 ro_dir.chmod(0o500) # no write
613 try:
614 result = setup_wizard.write_api_key(ro_dir / "sub" / ".env", "sc_key")
615 assert result is False
616 finally:
617 ro_dir.chmod(0o700) # restore so tempdir cleanup succeeds
618
619
620 class TestMaskApiKey:
621 """Tests for mask_api_key() — non-secret display form."""
622
623 def test_masks_long_key(self):
624 masked = setup_wizard.mask_api_key("sc_live_abcdef123456")
625 assert "abcdef" not in masked
626 assert masked.endswith("3456")
627 assert masked.startswith("sc_")
628
629 def test_short_key_collapses_to_placeholder(self):
630 assert setup_wizard.mask_api_key("short") == "sc_…"
631
632 def test_empty_key_collapses_to_placeholder(self):
633 assert setup_wizard.mask_api_key("") == "sc_…"
634
635
636 class TestCookieExtractionBrowsers:
637 """Tests for env.cookie_extraction_browsers() — the shared browser policy."""
638
639 def test_default_disables_extraction(self):
640 """FROM_BROWSER unset -> no browser-cookie reads."""
641 from lib import env
642 browsers = env.cookie_extraction_browsers({})
643 assert browsers == []
644
645 def test_off_disables_extraction(self):
646 from lib import env
647 assert env.cookie_extraction_browsers({"FROM_BROWSER": "off"}) == []
648
649 def test_auto_opts_into_chrome(self):
650 from lib import env
651 assert "chrome" in env.cookie_extraction_browsers({"FROM_BROWSER": "auto"})
652
653 def test_specific_browser(self):
654 from lib import env
655 assert env.cookie_extraction_browsers({"FROM_BROWSER": "chrome"}) == ["chrome"]
656
657
658 class TestWizardDoesNotProbeChromeByDefault:
659 """Regression: first-run setup must not silently read Chrome cookies."""
660
661 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
662 @patch("shutil.which", return_value=None)
663 def test_default_run_never_requests_chrome(self, _mock_which, mock_extract):
664 setup_wizard.run_auto_setup({})
665 requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
666 assert requested_browsers == set()
667
668 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
669 @patch("shutil.which", return_value=None)
670 def test_from_browser_auto_does_request_chrome(self, _mock_which, mock_extract):
671 setup_wizard.run_auto_setup({"FROM_BROWSER": "auto"}, allow_browser_cookies=True)
672 requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
673 assert "chrome" in requested_browsers
674
675 @patch("lib.cookie_extract.extract_cookies_with_source")
676 @patch("shutil.which", return_value=None)
677 def test_from_browser_off_skips_extraction(self, _mock_which, mock_extract):
678 results = setup_wizard.run_auto_setup({"FROM_BROWSER": "off"})
679 mock_extract.assert_not_called()
680 assert results["cookies_found"] == {}
681
682
683 class TestGetSetupStatusText:
684 """Tests for get_setup_status_text()."""
685
686 def test_with_cookies_and_ytdlp(self):
687 """Status text mentions found cookies and yt-dlp."""
688 results = {
689 "cookies_found": {"x": "chrome"},
690 "ytdlp_installed": True,
691 "ytdlp_action": "already_installed",
692 "env_written": True,
693 }
694 text = setup_wizard.get_setup_status_text(results)
695 assert "X cookies found in chrome" in text
696 assert "yt-dlp already installed" in text
697 assert "Configuration saved" in text
698
699 def test_with_no_cookies_no_ytdlp(self):
700 """Status text shows no cookies and suggests yt-dlp install."""
701 results = {
702 "cookies_found": {},
703 "ytdlp_installed": False,
704 "ytdlp_action": "no_homebrew",
705 "env_written": False,
706 }
707 text = setup_wizard.get_setup_status_text(results)
708 assert "No browser cookies found" in text
709 assert "Install Homebrew first" in text
710
711 def test_status_text_installed(self):
712 """Status text for freshly installed yt-dlp."""
713 results = {
714 "cookies_found": {},
715 "ytdlp_installed": True,
716 "ytdlp_action": "installed",
717 "env_written": False,
718 }
719 text = setup_wizard.get_setup_status_text(results)
720 assert "Installed yt-dlp via Homebrew" in text
721
722 def test_status_text_install_failed(self):
723 """Status text for failed yt-dlp install."""
724 results = {
725 "cookies_found": {},
726 "ytdlp_installed": False,
727 "ytdlp_action": "install_failed",
728 "env_written": False,
729 }
730 text = setup_wizard.get_setup_status_text(results)
731 assert "yt-dlp install failed" in text
732 assert "manually" in text
733
734 def test_status_text_digg_installed(self):
735 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
736 "digg_action": "installed", "env_written": False}
737 text = setup_wizard.get_setup_status_text(results)
738 assert "Installed Digg CLI" in text
739
740 def test_status_text_digg_already_installed(self):
741 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
742 "digg_action": "already_installed", "env_written": False}
743 text = setup_wizard.get_setup_status_text(results)
744 assert "Digg CLI already installed" in text
745
746 def test_status_text_digg_install_failed(self):
747 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
748 "digg_action": "install_failed", "env_written": False}
749 text = setup_wizard.get_setup_status_text(results)
750 assert "Digg CLI install failed" in text
751 assert "printing-press-library" in text
752
753 def test_status_text_digg_installed_off_path(self):
754 home = Path.home()
755 digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
756 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
757 "digg_action": "installed_off_path",
758 "digg_path": digg_path,
759 "env_written": False}
760 text = setup_wizard.get_setup_status_text(results)
761 assert "not on PATH" in text
762 assert "$HOME/.local/bin" in text
763 assert "now active" not in text.lower()
764
765 def test_status_text_digg_installed_off_path_legacy_go_bin(self):
766 """PATH hint names the actual install dir as $HOME-relative, not ~/.local/bin."""
767 home = Path.home()
768 digg_path = str(home / "go" / "bin" / "digg-pp-cli")
769 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
770 "digg_action": "installed_off_path",
771 "digg_path": digg_path,
772 "env_written": False}
773 text = setup_wizard.get_setup_status_text(results)
774 assert "$HOME/go/bin" in text
775 assert ".local/bin" not in text
776
777 def test_status_text_digg_installed_off_path_missing_path(self):
778 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
779 "digg_action": "installed_off_path",
780 "env_written": False}
781 text = setup_wizard.get_setup_status_text(results)
782 assert "not on PATH" in text
783 assert "add its install directory to PATH" in text
784
785 def test_status_text_digg_installed_off_path_empty_path(self):
786 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
787 "digg_action": "installed_off_path",
788 "digg_path": "",
789 "env_written": False}
790 text = setup_wizard.get_setup_status_text(results)
791 assert "add its install directory to PATH" in text
792
793 def test_digg_bin_dir_hint_windows_returns_absolute_parent(self):
794 home = Path.home()
795 digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
796 expected = str(home / ".local" / "bin")
797 with patch.object(setup_wizard.os, "name", "nt"):
798 assert setup_wizard._digg_bin_dir_hint(digg_path) == expected
799
800 def test_status_text_digg_no_npx(self):
801 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
802 "digg_action": "no_npx", "env_written": False}
803 text = setup_wizard.get_setup_status_text(results)
804 assert "Digg CLI not installed" in text
805
806 def test_status_text_digg_absent_key_renders(self):
807 """No digg_action key (defensive) -> no Digg line, no error."""
808 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
809 "env_written": False}
810 text = setup_wizard.get_setup_status_text(results)
811 assert "Digg" not in text
812
813
814 class TestSetupSubcommand:
815 """Tests for setup subcommand detection in argument parsing."""
816
817 def test_setup_detected_as_topic(self):
818 """The word 'setup' is treated as the setup subcommand."""
819 # Simulate what argparse produces
820 import argparse
821 parser = argparse.ArgumentParser()
822 parser.add_argument("topic", nargs="*")
823 args = parser.parse_args(["setup"])
824 topic = " ".join(args.topic) if args.topic else None
825 assert topic is not None
826 assert topic.strip().lower() == "setup"
827
828 def test_normal_topic_not_setup(self):
829 """A normal topic is not confused with setup."""
830 import argparse
831 parser = argparse.ArgumentParser()
832 parser.add_argument("topic", nargs="*")
833 args = parser.parse_args(["AI", "video", "tools"])
834 topic = " ".join(args.topic) if args.topic else None
835 assert topic.strip().lower() != "setup"
836
836 lines PYTHON