feat: v2.9.6 — free-first NUX, cookie extraction, quality scoring

Setup wizard with consent-first cookie extraction (Chrome/Firefox/Safari),
yt-dlp auto-install, ScrapeCreators push, quality scoring (5 core sources),
status banner redesign, honest Reddit labeling, inline YouTube transcripts,
Exa free web search, Reddit public fallback, and post-research quality nudge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-29 14:32:52 -07:00
parent 4d6224f79a
commit 3803f3df4b
34 changed files with 6238 additions and 320 deletions
+350
View File
@@ -0,0 +1,350 @@
"""Tests for Chrome cookie extraction on macOS."""
import hashlib
import os
import sqlite3
import subprocess
import tempfile
from pathlib import Path
from unittest import mock
import pytest
from scripts.lib.chrome_cookies import (
CHROME_COOKIES_DB,
CHROME_IV_HEX,
CHROME_KEY_LENGTH,
CHROME_PBKDF2_ITERATIONS,
CHROME_SALT,
_derive_aes_key,
_get_chrome_encryption_key,
_get_db_version,
_remove_pkcs7_padding,
_decrypt_v10_value,
extract_chrome_cookies_macos,
)
# ---------------------------------------------------------------------------
# Helpers — create real encrypted cookie values using known key + system openssl
# ---------------------------------------------------------------------------
KNOWN_PASSPHRASE = b"test_passphrase_for_unit_tests"
KNOWN_AES_KEY = _derive_aes_key(KNOWN_PASSPHRASE)
def _encrypt_value_v10(plaintext: str, aes_key: bytes) -> bytes:
"""Encrypt a value the same way Chrome v10 does, using system openssl.
Returns b'v10' + AES-128-CBC ciphertext with PKCS7 padding.
"""
hex_key = aes_key.hex()
result = subprocess.run(
[
"openssl", "enc", "-aes-128-cbc", "-e",
"-K", hex_key,
"-iv", CHROME_IV_HEX,
],
input=plaintext.encode("utf-8"),
capture_output=True,
timeout=5,
)
assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
return b"v10" + result.stdout
def _encrypt_value_v10_with_sha_prefix(plaintext: str, aes_key: bytes) -> bytes:
"""Encrypt with a 32-byte SHA-256 prefix (Chrome 130+ style)."""
raw = b"\x00" * 32 + plaintext.encode("utf-8")
hex_key = aes_key.hex()
result = subprocess.run(
[
"openssl", "enc", "-aes-128-cbc", "-e",
"-K", hex_key,
"-iv", CHROME_IV_HEX,
],
input=raw,
capture_output=True,
timeout=5,
)
assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
return b"v10" + result.stdout
def _create_chrome_cookies_db(path: str, cookies: list[tuple], db_version: int = 20) -> None:
"""Create a minimal Chrome Cookies SQLite database.
cookies: list of (host_key, name, value, encrypted_value) tuples
"""
conn = sqlite3.connect(path)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
c.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('version', ?)", (str(db_version),))
c.execute(
"CREATE TABLE IF NOT EXISTS cookies ("
" host_key TEXT NOT NULL,"
" name TEXT NOT NULL,"
" value TEXT NOT NULL DEFAULT '',"
" encrypted_value BLOB NOT NULL DEFAULT x'',"
" path TEXT NOT NULL DEFAULT '/',"
" expires_utc INTEGER NOT NULL DEFAULT 0,"
" is_secure INTEGER NOT NULL DEFAULT 1,"
" is_httponly INTEGER NOT NULL DEFAULT 1,"
" creation_utc INTEGER NOT NULL DEFAULT 0,"
" last_access_utc INTEGER NOT NULL DEFAULT 0,"
" has_expires INTEGER NOT NULL DEFAULT 1,"
" is_persistent INTEGER NOT NULL DEFAULT 1,"
" priority INTEGER NOT NULL DEFAULT 1,"
" samesite INTEGER NOT NULL DEFAULT 0,"
" source_scheme INTEGER NOT NULL DEFAULT 2,"
" source_port INTEGER NOT NULL DEFAULT 443,"
" last_update_utc INTEGER NOT NULL DEFAULT 0"
")"
)
for host_key, name, value, encrypted_value in cookies:
c.execute(
"INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?)",
(host_key, name, value, encrypted_value),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# PKCS7 padding tests
# ---------------------------------------------------------------------------
class TestPkcs7Padding:
def test_valid_padding_1(self):
# 1 byte of padding
data = b"hello world!!!!!" + b"\x01"
assert _remove_pkcs7_padding(data) == b"hello world!!!!!"
def test_valid_padding_5(self):
data = b"hello world" + b"\x05\x05\x05\x05\x05"
assert _remove_pkcs7_padding(data) == b"hello world"
def test_valid_padding_16(self):
# Full block of padding
data = b"\x10" * 16
assert _remove_pkcs7_padding(data) == b""
def test_invalid_padding_zero(self):
data = b"hello\x00"
assert _remove_pkcs7_padding(data) is None
def test_invalid_padding_mismatch(self):
data = b"hello\x03\x03\x02"
assert _remove_pkcs7_padding(data) is None
def test_empty_data(self):
assert _remove_pkcs7_padding(b"") is None
# ---------------------------------------------------------------------------
# Key derivation test
# ---------------------------------------------------------------------------
class TestKeyDerivation:
def test_derive_aes_key_deterministic(self):
key1 = _derive_aes_key(b"my_passphrase")
key2 = _derive_aes_key(b"my_passphrase")
assert key1 == key2
assert len(key1) == 16
def test_derive_aes_key_different_passphrases(self):
key1 = _derive_aes_key(b"passphrase_a")
key2 = _derive_aes_key(b"passphrase_b")
assert key1 != key2
# ---------------------------------------------------------------------------
# Decryption test (real openssl, known key)
# ---------------------------------------------------------------------------
class TestDecryption:
def test_decrypt_v10_roundtrip(self):
"""Encrypt then decrypt — verifies the full pipeline works."""
original = "my_secret_cookie_value_12345"
encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
assert encrypted[:3] == b"v10"
decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
assert decrypted == original
def test_decrypt_v10_chrome130_with_sha_prefix(self):
"""Chrome 130+ (db_version >= 24) strips 32-byte SHA-256 prefix."""
original = "session_token_abc"
encrypted = _encrypt_value_v10_with_sha_prefix(original, KNOWN_AES_KEY)
decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=24)
assert decrypted == original
def test_decrypt_wrong_key_returns_none_or_garbage(self):
"""Wrong key should either fail decryption or produce garbage."""
original = "secret"
encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
wrong_key = _derive_aes_key(b"wrong_passphrase")
result = _decrypt_v10_value(encrypted, wrong_key, db_version=20)
# Either None (padding check fails) or garbage (not the original)
assert result is None or result != original
def test_decrypt_empty_ciphertext(self):
"""v10 prefix with no ciphertext should return None."""
assert _decrypt_v10_value(b"v10", KNOWN_AES_KEY, db_version=20) is None
# ---------------------------------------------------------------------------
# Chrome not installed → returns None
# ---------------------------------------------------------------------------
class TestChromeNotInstalled:
def test_db_not_found(self):
with mock.patch(
"scripts.lib.chrome_cookies.CHROME_COOKIES_DB",
Path("/nonexistent/path/Cookies"),
):
result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
assert result is None
# ---------------------------------------------------------------------------
# Keychain access denied → returns None
# ---------------------------------------------------------------------------
class TestKeychainDenied:
def test_security_command_fails(self):
with mock.patch("scripts.lib.chrome_cookies.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=44, stdout="", stderr="security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain."
)
result = _get_chrome_encryption_key()
assert result is None
def test_security_command_not_found(self):
with mock.patch("scripts.lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
result = _get_chrome_encryption_key()
assert result is None
# ---------------------------------------------------------------------------
# openssl not found → returns None
# ---------------------------------------------------------------------------
class TestOpensslNotFound:
def test_openssl_missing(self):
encrypted = _encrypt_value_v10("test", KNOWN_AES_KEY)
with mock.patch("scripts.lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
result = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
assert result is None
# ---------------------------------------------------------------------------
# Unencrypted cookie values → returned as-is
# ---------------------------------------------------------------------------
class TestUnencryptedCookies:
def test_plain_value_returned(self, tmp_path):
"""Unencrypted cookies (value column populated) returned without decryption."""
db_path = str(tmp_path / "Cookies")
_create_chrome_cookies_db(db_path, [
(".x.com", "auth_token", "plain_token_value", b""),
(".x.com", "ct0", "plain_ct0_value", b""),
])
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
# No keychain needed for unencrypted values
with mock.patch("scripts.lib.chrome_cookies._get_chrome_encryption_key", return_value=None):
result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
assert result == {"auth_token": "plain_token_value", "ct0": "plain_ct0_value"}
# ---------------------------------------------------------------------------
# Full integration: mock DB with real v10 encryption, mock Keychain
# ---------------------------------------------------------------------------
class TestFullExtraction:
def test_encrypted_cookies_extracted(self, tmp_path):
"""End-to-end: create DB with real v10-encrypted values, extract them."""
auth_val = "my_auth_token_123"
ct0_val = "my_ct0_csrf_456"
encrypted_auth = _encrypt_value_v10(auth_val, KNOWN_AES_KEY)
encrypted_ct0 = _encrypt_value_v10(ct0_val, KNOWN_AES_KEY)
db_path = str(tmp_path / "Cookies")
_create_chrome_cookies_db(db_path, [
(".x.com", "auth_token", "", encrypted_auth),
(".x.com", "ct0", "", encrypted_ct0),
(".other.com", "other", "", b""), # unrelated cookie
])
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
with mock.patch(
"scripts.lib.chrome_cookies._get_chrome_encryption_key",
return_value=KNOWN_PASSPHRASE,
):
result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == auth_val
assert result["ct0"] == ct0_val
def test_no_matching_cookies_returns_none(self, tmp_path):
db_path = str(tmp_path / "Cookies")
_create_chrome_cookies_db(db_path, [
(".other.com", "session", "val", b""),
])
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
with mock.patch("scripts.lib.chrome_cookies._get_chrome_encryption_key", return_value=None):
result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
assert result is None
def test_chrome130_db_version_24(self, tmp_path):
"""Chrome 130+ with db_version >= 24 strips SHA-256 prefix."""
auth_val = "token_for_chrome130"
encrypted_auth = _encrypt_value_v10_with_sha_prefix(auth_val, KNOWN_AES_KEY)
db_path = str(tmp_path / "Cookies")
_create_chrome_cookies_db(db_path, [
(".x.com", "auth_token", "", encrypted_auth),
], db_version=24)
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
with mock.patch(
"scripts.lib.chrome_cookies._get_chrome_encryption_key",
return_value=KNOWN_PASSPHRASE,
):
result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
assert result is not None
assert result["auth_token"] == auth_val
# ---------------------------------------------------------------------------
# DB version detection
# ---------------------------------------------------------------------------
class TestDbVersion:
def test_reads_version_from_meta(self, tmp_path):
db_path = str(tmp_path / "test.db")
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("CREATE TABLE meta (key TEXT, value TEXT)")
c.execute("INSERT INTO meta VALUES ('version', '24')")
conn.commit()
assert _get_db_version(c) == 24
conn.close()
def test_no_meta_table_returns_zero(self, tmp_path):
db_path = str(tmp_path / "test.db")
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("CREATE TABLE dummy (x TEXT)")
conn.commit()
assert _get_db_version(c) == 0
conn.close()
+4 -2
View File
@@ -124,7 +124,8 @@ class TestLoadCodexAuth(unittest.TestCase):
class TestGetAvailableSourcesWithAuth(unittest.TestCase):
def test_codex_auth_ok_counts_as_openai(self):
@patch("lib.bird_x.is_bird_installed", return_value=False)
def test_codex_auth_ok_counts_as_openai(self, _mock_bird):
config = {
"OPENAI_API_KEY": "codex-token",
"OPENAI_AUTH_STATUS": "ok",
@@ -133,7 +134,8 @@ class TestGetAvailableSourcesWithAuth(unittest.TestCase):
result = env.get_available_sources(config)
self.assertIn("reddit", result)
def test_codex_auth_expired_not_counted(self):
@patch("lib.bird_x.is_bird_installed", return_value=False)
def test_codex_auth_expired_not_counted(self, _mock_bird):
config = {
"OPENAI_API_KEY": None,
"OPENAI_AUTH_STATUS": "expired",
+293
View File
@@ -0,0 +1,293 @@
"""Tests for browser cookie extraction module."""
import configparser
import sqlite3
import textwrap
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from unittest.mock import patch
import pytest
from scripts.lib.cookie_extract import (
extract_cookies,
extract_firefox_cookies,
_find_default_profile,
_get_firefox_profiles_dir,
)
@pytest.fixture
def mock_firefox_env(tmp_path):
"""Create a mock Firefox profiles directory with cookies.sqlite.
Returns (profiles_dir, profile_dir) for patching.
"""
def _make(
*,
profiles_ini=None, # type: Optional[str]
profiles=None, # type: Optional[Dict[str, List[Tuple[str, str, str]]]]
default_profile="abc123.default-release", # type: str
):
profiles_dir = tmp_path / "Firefox"
profiles_dir.mkdir(parents=True, exist_ok=True)
# Default: one profile with X cookies
if profiles is None:
profiles = {
default_profile: [
(".x.com", "auth_token", "tok_abc123"),
(".x.com", "ct0", "ct0_xyz789"),
(".example.com", "session", "sess_other"),
],
}
# Create profile directories with cookies databases
for profile_name, cookies in profiles.items():
profile_dir = profiles_dir / profile_name
profile_dir.mkdir(parents=True, exist_ok=True)
db_path = profile_dir / "cookies.sqlite"
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE moz_cookies ("
" id INTEGER PRIMARY KEY,"
" name TEXT NOT NULL,"
" value TEXT NOT NULL,"
" host TEXT NOT NULL,"
" path TEXT DEFAULT '/',"
" expiry INTEGER DEFAULT 0,"
" isSecure INTEGER DEFAULT 1,"
" isHttpOnly INTEGER DEFAULT 1,"
" sameSite INTEGER DEFAULT 0,"
" schemeMap INTEGER DEFAULT 0"
")"
)
for host, name, value in cookies:
conn.execute(
"INSERT INTO moz_cookies (name, value, host) VALUES (?, ?, ?)",
(name, value, host),
)
conn.commit()
conn.close()
# Write profiles.ini
if profiles_ini is None:
profiles_ini = textwrap.dedent(f"""\
[General]
StartWithLastProfile=1
[Profile0]
Name=default-release
IsRelative=1
Path={default_profile}
Default=1
""")
(profiles_dir / "profiles.ini").write_text(profiles_ini)
return profiles_dir
return _make
class TestExtractFirefoxCookies:
"""Tests for extract_firefox_cookies."""
def test_valid_cookies_extracted(self, mock_firefox_env):
"""Cookies for the target domain are returned correctly."""
profiles_dir = mock_firefox_env()
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == "tok_abc123"
assert result["ct0"] == "ct0_xyz789"
assert "session" not in result # different domain cookie not included
def test_multiple_profiles_selects_default(self, mock_firefox_env):
"""When multiple profiles exist, the one with Default=1 is used."""
profiles_dir = mock_firefox_env(
profiles={
"aaa111.other": [
(".x.com", "auth_token", "wrong_token"),
],
"bbb222.default-release": [
(".x.com", "auth_token", "correct_token"),
(".x.com", "ct0", "correct_ct0"),
],
},
profiles_ini=textwrap.dedent("""\
[General]
StartWithLastProfile=1
[Profile0]
Name=other
IsRelative=1
Path=aaa111.other
[Profile1]
Name=default-release
IsRelative=1
Path=bbb222.default-release
Default=1
"""),
)
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == "correct_token"
assert result["ct0"] == "correct_ct0"
def test_firefox_not_installed(self):
"""Returns None when Firefox profiles directory doesn't exist."""
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=None,
):
result = extract_firefox_cookies(".x.com", ["auth_token"])
assert result is None
def test_cookies_sqlite_empty(self, mock_firefox_env):
"""Returns None when cookies.sqlite has no rows."""
profiles_dir = mock_firefox_env(
profiles={"abc123.default-release": []}, # no cookies
)
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
assert result is None
def test_domain_has_no_cookies(self, mock_firefox_env):
"""Returns None when cookies exist but not for the target domain."""
profiles_dir = mock_firefox_env(
profiles={
"abc123.default-release": [
(".example.com", "session", "sess_123"),
],
},
)
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
assert result is None
def test_malformed_profiles_ini_falls_back(self, mock_firefox_env):
"""Falls back to first profile on disk when profiles.ini is garbage."""
profiles_dir = mock_firefox_env(
profiles={
"zzz999.fallback": [
(".x.com", "auth_token", "fallback_token"),
],
},
profiles_ini="this is not valid ini content\n[[[broken",
)
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_firefox_cookies(".x.com", ["auth_token"])
assert result is not None
assert result["auth_token"] == "fallback_token"
class TestExtractCookiesAuto:
"""Tests for extract_cookies with browser='auto'."""
def test_auto_macos_tries_chrome_then_firefox(self, mock_firefox_env):
"""On macOS, auto tries Chrome first, falls back to Firefox if Chrome fails."""
profiles_dir = mock_firefox_env()
with (
patch("scripts.lib.cookie_extract.platform.system", return_value="Darwin"),
patch(
"scripts.lib.cookie_extract.extract_chrome_cookies",
return_value=None,
),
patch(
"scripts.lib.cookie_extract.extract_safari_cookies",
return_value=None,
),
patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
),
):
result = extract_cookies("auto", ".x.com", ["auth_token", "ct0"])
# Chrome and Safari return None, Firefox succeeds
assert result is not None
assert result["auth_token"] == "tok_abc123"
assert result["ct0"] == "ct0_xyz789"
def test_auto_linux_tries_firefox_only(self, mock_firefox_env):
"""On Linux, auto only tries Firefox."""
profiles_dir = mock_firefox_env()
with (
patch("scripts.lib.cookie_extract.platform.system", return_value="Linux"),
patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
),
):
result = extract_cookies("auto", ".x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == "tok_abc123"
def test_explicit_firefox(self, mock_firefox_env):
"""Explicit browser='firefox' goes directly to Firefox."""
profiles_dir = mock_firefox_env()
with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=profiles_dir,
):
result = extract_cookies("firefox", ".x.com", ["auth_token"])
assert result is not None
assert result["auth_token"] == "tok_abc123"
def test_unknown_browser_returns_none(self):
"""Unknown browser name returns None."""
result = extract_cookies("netscape", ".x.com", ["auth_token"])
assert result is None
def test_chrome_delegates_to_chrome_module(self):
"""Chrome extraction delegates to chrome_cookies module."""
with patch(
"scripts.lib.cookie_extract.extract_chrome_cookies",
return_value={"auth_token": "chrome_tok"},
):
result = extract_cookies("chrome", ".x.com", ["auth_token"])
assert result == {"auth_token": "chrome_tok"}
def test_safari_delegates_to_safari_module(self):
"""Safari extraction delegates to safari_cookies module."""
with patch(
"scripts.lib.cookie_extract.extract_safari_cookies",
return_value={"auth_token": "safari_tok"},
):
result = extract_cookies("safari", ".x.com", ["auth_token"])
assert result == {"auth_token": "safari_tok"}
+185
View File
@@ -0,0 +1,185 @@
"""Tests for browser cookie extraction integration in env.py."""
from unittest.mock import patch, MagicMock
import pytest
from scripts.lib.env import extract_browser_credentials, COOKIE_DOMAINS
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict with common defaults."""
cfg = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": None,
"SETUP_COMPLETE": None,
}
cfg.update(overrides)
return cfg
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestExtractBrowserCredentials:
"""Unit tests for extract_browser_credentials()."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_auto_with_setup_complete_populates_credentials(self, mock_extract):
"""FROM_BROWSER=auto, SETUP_COMPLETE=true, mock returns valid cookies
-> config now contains AUTH_TOKEN and CT0."""
mock_extract.return_value = ({"auth_token": "tok123", "ct0": "ct0val"}, "chrome")
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "tok123"
assert result["CT0"] == "ct0val"
# Should have been called for x domain
mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_explicit_auth_token_skips_x_extraction(self, mock_extract):
"""Config already has AUTH_TOKEN and CT0 from env var
-> cookie extraction skipped for X (explicit takes priority)."""
# Return None for any non-X domains that still get checked (e.g. Truth Social)
mock_extract.return_value = None
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
FROM_BROWSER="auto",
SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
# X cookies should not appear in result (already set)
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
# extract_cookies should NOT have been called for .x.com
for call in mock_extract.call_args_list:
assert call[0][1] != ".x.com", "Should not extract cookies for X when credentials already set"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_from_browser_off_skips_all(self, mock_extract):
"""FROM_BROWSER=off -> no cookie extraction attempted."""
config = _base_config(FROM_BROWSER="off", SETUP_COMPLETE="true")
result = extract_browser_credentials(config)
assert result == {}
mock_extract.assert_not_called()
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_no_setup_complete_no_from_browser_defaults_off(self, mock_extract):
"""FROM_BROWSER not set and SETUP_COMPLETE not set
-> no extraction (wizard hasn't run)."""
config = _base_config() # both None
result = extract_browser_credentials(config)
assert result == {}
mock_extract.assert_not_called()
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_setup_complete_no_from_browser_defaults_auto(self, mock_extract):
"""SETUP_COMPLETE is set but FROM_BROWSER is not
-> defaults to 'auto'."""
mock_extract.return_value = ({"auth_token": "found", "ct0": "found_ct0"}, "firefox")
config = _base_config(SETUP_COMPLETE="true") # FROM_BROWSER=None
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "found"
# extract_cookies should have been called with 'auto'
mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_from_browser_firefox_only(self, mock_extract):
"""FROM_BROWSER=firefox -> only Firefox extraction attempted."""
mock_extract.return_value = ({"auth_token": "ff_tok", "ct0": "ff_ct0"}, "firefox")
config = _base_config(FROM_BROWSER="firefox", SETUP_COMPLETE="true")
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "ff_tok"
# All calls should use 'firefox' as the browser arg
for call in mock_extract.call_args_list:
assert call[0][0] == "firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_extraction_returns_none_config_unchanged(self, mock_extract):
"""Cookie extraction returns None for X -> config unchanged for AUTH_TOKEN."""
mock_extract.return_value = None
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
result = extract_browser_credentials(config)
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_extraction_raises_exception_config_unchanged(self, mock_extract):
"""Cookie extraction raises exception -> caught, config unchanged."""
mock_extract.side_effect = RuntimeError("database locked")
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
result = extract_browser_credentials(config)
# Should not raise, and no credentials populated
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_partial_credentials_only_fills_missing(self, mock_extract):
"""If AUTH_TOKEN is set but CT0 is not, only CT0 gets filled."""
mock_extract.return_value = ({"auth_token": "cookie_tok", "ct0": "cookie_ct0"}, "chrome")
config = _base_config(
AUTH_TOKEN="explicit",
CT0=None,
FROM_BROWSER="auto",
SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
# AUTH_TOKEN already set, should not be overridden
assert "AUTH_TOKEN" not in result
# CT0 was missing, should be filled
assert result["CT0"] == "cookie_ct0"
class TestGetConfigCookieIntegration:
"""Integration test: get_config() calls extract_browser_credentials."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_get_config_injects_cookies(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""get_config merges browser cookies into the returned config."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = ({"auth_token": "browser_tok", "ct0": "browser_ct0"}, "firefox")
import os
env_patch = {
"SETUP_COMPLETE": "true",
"FROM_BROWSER": "auto",
"LAST30DAYS_CONFIG_DIR": "",
}
with patch.dict(os.environ, env_patch, clear=False):
config = get_config()
assert config["AUTH_TOKEN"] == "browser_tok"
assert config["CT0"] == "browser_ct0"
+3 -1
View File
@@ -223,7 +223,9 @@ class TestXSourceSelection(unittest.TestCase):
'can_install': False,
}
with patch('lib.bird_x.get_bird_status', return_value=bird_status):
with patch('lib.bird_x.get_bird_status', return_value=bird_status), \
patch('lib.bird_x.is_bird_installed', return_value=True), \
patch('lib.bird_x.is_bird_authenticated', return_value=None):
status = env.get_x_source_status(config)
self.assertIsNone(status['source'])
+249
View File
@@ -0,0 +1,249 @@
"""Tests for Exa Search module."""
import sys
import os
import unittest
from unittest.mock import patch, MagicMock
# Ensure scripts/ is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
# Force clean config mode for tests
os.environ['LAST30DAYS_CONFIG_DIR'] = ''
from lib.exa_search import search_web, _normalize_results, _parse_exa_date, EXCLUDED_DOMAINS
from lib import env, http
class TestNormalizeResults(unittest.TestCase):
"""Test result normalization from Exa API responses."""
def test_valid_results(self):
response = {
"results": [
{
"title": "AI Trends in 2026",
"url": "https://blog.example.com/ai-trends",
"text": "This article covers the latest AI trends...",
"publishedDate": "2026-03-15T00:00:00.000Z",
"score": 0.85,
},
{
"title": "Machine Learning Updates",
"url": "https://ml.example.com/updates",
"text": "New ML frameworks released this month...",
"publishedDate": "2026-03-10T12:30:00.000Z",
"score": 0.72,
},
]
}
items = _normalize_results(response)
self.assertEqual(len(items), 2)
# Check first item structure
item = items[0]
self.assertEqual(item["title"], "AI Trends in 2026")
self.assertEqual(item["url"], "https://blog.example.com/ai-trends")
self.assertIn("AI trends", item["snippet"])
self.assertEqual(item["date"], "2026-03-15")
self.assertEqual(item["date_confidence"], "med")
self.assertAlmostEqual(item["relevance"], 0.85)
self.assertEqual(item["id"], "W1")
self.assertEqual(item["source_domain"], "blog.example.com")
def test_excludes_reddit_and_x(self):
response = {
"results": [
{"title": "Reddit post", "url": "https://www.reddit.com/r/test/123", "text": "Post"},
{"title": "X post", "url": "https://x.com/user/status/456", "text": "Tweet"},
{"title": "Good result", "url": "https://example.com/article", "text": "Content"},
]
}
items = _normalize_results(response)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["title"], "Good result")
def test_empty_results(self):
items = _normalize_results({"results": []})
self.assertEqual(items, [])
def test_missing_results_key(self):
items = _normalize_results({})
self.assertEqual(items, [])
def test_skips_items_without_url(self):
response = {
"results": [
{"title": "No URL", "text": "Content"},
{"title": "Has URL", "url": "https://example.com/a", "text": "Content"},
]
}
items = _normalize_results(response)
self.assertEqual(len(items), 1)
def test_skips_items_without_title_and_snippet(self):
response = {
"results": [
{"url": "https://example.com/a", "title": "", "text": ""},
{"url": "https://example.com/b", "title": "Valid", "text": "Content"},
]
}
items = _normalize_results(response)
self.assertEqual(len(items), 1)
def test_no_date_gives_low_confidence(self):
response = {
"results": [
{"title": "No date", "url": "https://example.com/a", "text": "Content"},
]
}
items = _normalize_results(response)
self.assertEqual(items[0]["date"], None)
self.assertEqual(items[0]["date_confidence"], "low")
def test_truncates_long_fields(self):
response = {
"results": [
{
"title": "T" * 300,
"url": "https://example.com/a",
"text": "S" * 1000,
},
]
}
items = _normalize_results(response)
self.assertLessEqual(len(items[0]["title"]), 200)
self.assertLessEqual(len(items[0]["snippet"]), 500)
def test_relevance_clamped(self):
response = {
"results": [
{"title": "High", "url": "https://example.com/a", "text": "C", "score": 1.5},
{"title": "Low", "url": "https://example.com/b", "text": "C", "score": -0.5},
]
}
items = _normalize_results(response)
self.assertEqual(items[0]["relevance"], 1.0)
self.assertEqual(items[1]["relevance"], 0.0)
class TestParseExaDate(unittest.TestCase):
def test_iso_datetime(self):
self.assertEqual(_parse_exa_date("2026-03-15T00:00:00.000Z"), "2026-03-15")
def test_date_only(self):
self.assertEqual(_parse_exa_date("2026-03-15"), "2026-03-15")
def test_none(self):
self.assertIsNone(_parse_exa_date(None))
def test_empty_string(self):
self.assertIsNone(_parse_exa_date(""))
class TestSearchWebIntegration(unittest.TestCase):
"""Test search_web function with mocked HTTP calls."""
@patch("lib.exa_search.http.post")
def test_valid_search(self, mock_post):
mock_post.return_value = {
"results": [
{
"title": "Test Result",
"url": "https://example.com/test",
"text": "Test content here",
"publishedDate": "2026-03-20T00:00:00.000Z",
"score": 0.9,
},
]
}
results = search_web("AI news", "2026-03-01", "2026-03-29", "test-api-key")
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["title"], "Test Result")
self.assertEqual(results[0]["url"], "https://example.com/test")
self.assertEqual(results[0]["snippet"], "Test content here")
# Verify API call
mock_post.assert_called_once()
call_args = mock_post.call_args
self.assertEqual(call_args[0][0], "https://api.exa.ai/search")
self.assertEqual(call_args[1]["headers"]["x-api-key"], "test-api-key")
@patch("lib.exa_search.http.post")
def test_401_invalid_key(self, mock_post):
mock_post.side_effect = http.HTTPError("HTTP 401: Unauthorized", status_code=401)
results = search_web("AI news", "2026-03-01", "2026-03-29", "bad-key")
self.assertEqual(results, [])
@patch("lib.exa_search.http.post")
def test_429_rate_limit(self, mock_post):
mock_post.side_effect = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
results = search_web("AI news", "2026-03-01", "2026-03-29", "test-key")
self.assertEqual(results, [])
@patch("lib.exa_search.http.post")
def test_empty_results(self, mock_post):
mock_post.return_value = {"results": []}
results = search_web("obscure query", "2026-03-01", "2026-03-29", "test-key")
self.assertEqual(results, [])
@patch("lib.exa_search.http.post")
def test_network_timeout(self, mock_post):
mock_post.side_effect = http.HTTPError("Connection error: TimeoutError: timed out")
results = search_web("AI news", "2026-03-01", "2026-03-29", "test-key")
self.assertEqual(results, [])
@patch("lib.exa_search.http.post")
def test_generic_exception(self, mock_post):
mock_post.side_effect = Exception("Something unexpected")
results = search_web("AI news", "2026-03-01", "2026-03-29", "test-key")
self.assertEqual(results, [])
class TestEnvExaPriority(unittest.TestCase):
"""Test that Exa is prioritized correctly in env.py."""
def test_no_exa_key_not_selected(self):
config = {}
self.assertIsNone(env.get_web_search_source(config))
def test_exa_key_selected(self):
config = {"EXA_API_KEY": "exa-test-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_brave(self):
config = {"EXA_API_KEY": "exa-key", "BRAVE_API_KEY": "brave-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_parallel(self):
config = {"EXA_API_KEY": "exa-key", "PARALLEL_API_KEY": "parallel-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_openrouter(self):
config = {"EXA_API_KEY": "exa-key", "OPENROUTER_API_KEY": "or-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_all(self):
config = {
"EXA_API_KEY": "exa-key",
"PARALLEL_API_KEY": "parallel-key",
"BRAVE_API_KEY": "brave-key",
"OPENROUTER_API_KEY": "or-key",
}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_fallback_to_parallel_without_exa(self):
config = {"PARALLEL_API_KEY": "parallel-key", "BRAVE_API_KEY": "brave-key"}
self.assertEqual(env.get_web_search_source(config), "parallel")
def test_has_web_search_keys_with_exa(self):
config = {"EXA_API_KEY": "exa-key"}
self.assertTrue(env.has_web_search_keys(config))
def test_has_web_search_keys_without_any(self):
config = {}
self.assertFalse(env.has_web_search_keys(config))
if __name__ == "__main__":
unittest.main()
+411
View File
@@ -0,0 +1,411 @@
"""End-to-end NUX integration tests.
Verifies that setup wizard, status banner, quality nudge, and SKILL.md
first-run flow work together coherently:
- first_run flag emitted / not emitted based on SETUP_COMPLETE
- setup subcommand runs auto_setup and writes config
- quality score is consistent with source configuration
- quality nudge disappears at 100%
- banner and quality nudge don't contradict each other
"""
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# Add scripts dir to path
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from lib import setup_wizard, quality_nudge, ui
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict."""
config = {
"AUTH_TOKEN": None,
"CT0": None,
"XAI_API_KEY": None,
"SCRAPECREATORS_API_KEY": None,
}
config.update(overrides)
return config
def _base_results(**overrides):
"""Return a minimal research_results dict with no errors."""
results = {
"x_error": None,
"youtube_error": None,
"reddit_error": None,
}
results.update(overrides)
return results
def _base_diag(**overrides):
"""Return a minimal diag dict for banner testing."""
diag = {
"setup_complete": False,
"reddit_source": None,
"x_source": None,
"x_method": None,
"youtube": False,
"tiktok": False,
"instagram": False,
"hackernews": True,
"polymarket": True,
"bluesky": False,
"truthsocial": False,
"xiaohongshu": False,
"scrapecreators": False,
"web_search_backend": None,
}
diag.update(overrides)
return diag
def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
"""Helper to call compute_quality_score with mocked yt-dlp check."""
from lib import youtube_yt
config = _base_config(**(config_overrides or {}))
results = _base_results(**(result_overrides or {}))
with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
return quality_nudge.compute_quality_score(config, results)
# ---------------------------------------------------------------------------
# First-run flag detection
# ---------------------------------------------------------------------------
class TestFirstRunDetection:
"""first_run flag is emitted based on SETUP_COMPLETE in config."""
def test_first_run_when_setup_not_complete(self):
"""SETUP_COMPLETE missing -> is_first_run returns True."""
config = _base_config()
assert setup_wizard.is_first_run(config) is True
def test_first_run_when_setup_complete_empty(self):
"""SETUP_COMPLETE='' -> is_first_run returns True."""
config = _base_config(SETUP_COMPLETE="")
assert setup_wizard.is_first_run(config) is True
def test_not_first_run_when_setup_complete(self):
"""SETUP_COMPLETE=true -> is_first_run returns False."""
config = _base_config(SETUP_COMPLETE="true")
assert setup_wizard.is_first_run(config) is False
def test_not_first_run_any_truthy_value(self):
"""SETUP_COMPLETE=1 -> is_first_run returns False."""
config = _base_config(SETUP_COMPLETE="1")
assert setup_wizard.is_first_run(config) is False
# ---------------------------------------------------------------------------
# Setup subcommand writes config
# ---------------------------------------------------------------------------
class TestSetupSubcommandWritesConfig:
"""setup subcommand runs auto_setup and writes SETUP_COMPLETE."""
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_auto_setup_and_write(self, mock_which, mock_extract):
"""run_auto_setup + write_setup_config creates valid config."""
mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
mock_which.return_value = "/usr/local/bin/yt-dlp"
config = _base_config()
results = setup_wizard.run_auto_setup(config)
assert results["cookies_found"]["x"] == "chrome"
assert results["ytdlp_installed"] is True
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
written = setup_wizard.write_setup_config(env_path)
assert written is True
content = env_path.read_text()
assert "SETUP_COMPLETE=true" in content
assert "FROM_BROWSER=auto" in content
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_after_setup_not_first_run(self, mock_which, mock_extract):
"""After write_setup_config, is_first_run should return False."""
mock_extract.return_value = None
mock_which.return_value = None
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
setup_wizard.write_setup_config(env_path)
# Simulate reading the config back
config = {"SETUP_COMPLETE": "true"}
assert setup_wizard.is_first_run(config) is False
# ---------------------------------------------------------------------------
# Quality score consistency with source config
# ---------------------------------------------------------------------------
class TestQualityScoreConsistency:
"""Quality score matches the source configuration state."""
def test_zero_config_is_40_pct(self):
"""No X, no yt-dlp, no SC -> 40% (HN + Polymarket only)."""
q = _compute()
assert q["score_pct"] == 40
assert set(q["core_active"]) == {"hn", "polymarket"}
def test_x_cookies_is_60_pct(self):
"""X cookies active -> 60%."""
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["score_pct"] == 60
assert "x" in q["core_active"]
def test_x_plus_ytdlp_is_80_pct(self):
"""X cookies + yt-dlp -> 80%."""
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
assert "x" in q["core_active"]
assert "youtube" in q["core_active"]
def test_full_config_is_100_pct(self):
"""X + yt-dlp + ScrapeCreators -> 100%."""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["score_pct"] == 100
assert len(q["core_active"]) == 5
def test_xai_key_also_enables_x(self):
"""XAI_API_KEY activates X source same as cookies."""
q = _compute(config_overrides={"XAI_API_KEY": "xai_key"})
assert q["score_pct"] == 60
assert "x" in q["core_active"]
# ---------------------------------------------------------------------------
# Quality nudge disappears at 100%
# ---------------------------------------------------------------------------
class TestQualityNudgeDisappears:
"""Quality nudge is None at 100% and present below 100%."""
def test_nudge_present_at_40(self):
q = _compute()
assert q["nudge_text"] is not None
assert len(q["nudge_text"]) > 0
def test_nudge_present_at_60(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["nudge_text"] is not None
def test_nudge_present_at_80(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["nudge_text"] is not None
def test_nudge_none_at_100(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
assert q["core_missing"] == []
# ---------------------------------------------------------------------------
# Banner and quality nudge consistency
# ---------------------------------------------------------------------------
class TestBannerQualityNudgeConsistency:
"""Banner source count and quality nudge percentage don't contradict."""
def test_zero_config_banner_3_sources_nudge_40(self):
"""Banner shows 3 sources (Reddit, HN, PM), nudge says 40%."""
diag = _base_diag()
banner = "\n".join(ui._build_status_banner(diag))
# Banner shows 3 active sources
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
# X and YouTube should NOT be in the banner
assert "X (" not in banner
assert "YouTube" not in banner
# Quality nudge is 40%
q = _compute()
assert q["score_pct"] == 40
def test_after_wizard_banner_5_sources_nudge_80(self):
"""After wizard (X cookies + yt-dlp): banner shows 5+ sources, nudge ~80%."""
diag = _base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
youtube=True,
)
banner = "\n".join(ui._build_status_banner(diag))
# Banner shows X and YouTube
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
# Quality nudge is 80%
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
def test_full_config_banner_all_nudge_gone(self):
"""Full config: banner shows all sources, nudge disappears (100%)."""
diag = _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
scrapecreators=True,
)
banner = "\n".join(ui._build_status_banner(diag))
assert "Reddit (with comments)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "TikTok" in banner
assert "Instagram" in banner
# Quality nudge is 100% / gone
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["score_pct"] == 100
assert q["nudge_text"] is None
def test_banner_first_run_suggests_setup(self):
"""First-run banner suggests running setup wizard."""
diag = _base_diag(setup_complete=False)
banner = "\n".join(ui._build_status_banner(diag))
assert "First Run" in banner
assert "/last30days setup" in banner
def test_banner_partial_suggests_scrapecreators(self):
"""After setup, missing SC is the only recommendation."""
diag = _base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
youtube=True,
scrapecreators=False,
)
banner = "\n".join(ui._build_status_banner(diag))
assert "SCRAPECREATORS_API_KEY" in banner
assert "100 free calls, no CC" in banner
# ---------------------------------------------------------------------------
# Progressive narrowing of nudge suggestions
# ---------------------------------------------------------------------------
class TestProgressiveNarrowing:
"""As users configure more, nudge suggestions narrow."""
def test_baseline_mentions_all_three_missing(self):
q = _compute()
assert "X/Twitter" in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
def test_with_x_mentions_yt_and_sc(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert "X/Twitter" not in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
def test_with_x_and_yt_mentions_sc_only(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert "X/Twitter" not in q["nudge_text"]
assert "YouTube" not in q["nudge_text"]
# SC nudge is present
assert "Reddit" in q["nudge_text"] or "ScrapeCreators" in q["nudge_text"].lower() or "scrapecreators" in q["nudge_text"]
def test_full_coverage_no_nudge(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
# ---------------------------------------------------------------------------
# Render quality nudge integration
# ---------------------------------------------------------------------------
class TestRenderQualityNudge:
"""render_quality_nudge produces correct output."""
def test_render_at_80_pct(self):
from lib.render import render_quality_nudge
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
rendered = render_quality_nudge(q)
assert "80%" in rendered
assert "Research Coverage" in rendered
def test_render_at_100_pct_is_empty(self):
from lib.render import render_quality_nudge
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
rendered = render_quality_nudge(q)
assert rendered == ""
+203
View File
@@ -0,0 +1,203 @@
"""Tests for post-research quality score and upgrade nudge."""
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict."""
config = {
"AUTH_TOKEN": None,
"CT0": None,
"XAI_API_KEY": None,
"SCRAPECREATORS_API_KEY": None,
}
config.update(overrides)
return config
def _base_results(**overrides):
"""Return a minimal research_results dict with no errors."""
results = {
"x_error": None,
"youtube_error": None,
"reddit_error": None,
}
results.update(overrides)
return results
def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
"""Helper to call compute_quality_score with mocked yt-dlp check."""
from scripts.lib.quality_nudge import compute_quality_score
from scripts.lib import youtube_yt
config = _base_config(**(config_overrides or {}))
results = _base_results(**(result_overrides or {}))
with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
return compute_quality_score(config, results)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestBaseline:
"""HN + Polymarket only (no X, no YT, no SC) -> 40%."""
def test_score_40(self):
q = _compute()
assert q["score_pct"] == 40
def test_active_sources(self):
q = _compute()
assert "hn" in q["core_active"]
assert "polymarket" in q["core_active"]
assert len(q["core_active"]) == 2
def test_missing_all_three(self):
q = _compute()
assert set(q["core_missing"]) == {"x", "youtube", "reddit_comments"}
def test_nudge_mentions_all_missing(self):
q = _compute()
assert q["nudge_text"] is not None
assert "X/Twitter" in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
class TestXCookies:
"""+X cookies -> 60%."""
def test_score_60(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["score_pct"] == 60
def test_nudge_mentions_yt_and_sc(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
assert "X/Twitter" not in q["nudge_text"]
class TestXPlusYtdlp:
"""+X + yt-dlp -> 80%."""
def test_score_80(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
def test_nudge_mentions_sc_only(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert "ScrapeCreators" in q["nudge_text"] or "scrapecreators" in q["nudge_text"]
assert "YouTube" not in q["nudge_text"]
assert "X/Twitter" not in q["nudge_text"]
class TestFullCoverage:
"""+X + yt-dlp + SC -> 100%, no nudge."""
def test_score_100(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["score_pct"] == 100
def test_nudge_is_none(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
class TestSCActiveNoX:
"""SC active but no X -> 80%, nudge suggests browser cookies (free)."""
def test_score_80(self):
q = _compute(
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
def test_nudge_suggests_browser_cookies(self):
q = _compute(
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
ytdlp_installed=True,
)
assert q["nudge_text"] is not None
assert "browser" in q["nudge_text"].lower()
assert "x.com" in q["nudge_text"].lower()
class TestRedditErrored:
"""SC is configured but Reddit errored this run."""
def test_nudge_mentions_error(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
result_overrides={"reddit_error": "ScrapeCreators: 500 Internal Server Error"},
ytdlp_installed=True,
)
assert "reddit_comments" in q["core_errored"]
assert "errored" in q["nudge_text"].lower()
class TestDisclaimerAlwaysPresent:
"""Nudge always includes no-affiliate disclaimer when present."""
def test_disclaimer_baseline(self):
q = _compute()
assert "no affiliation" in q["nudge_text"]
def test_disclaimer_partial(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert "no affiliation" in q["nudge_text"]
def test_disclaimer_not_present_at_100(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
class TestSCNudgeContent:
"""SC nudge always includes '100 free API calls, no credit card'."""
def test_sc_nudge_content(self):
q = _compute()
assert "100 free API calls, no credit card" in q["nudge_text"]
def test_sc_nudge_content_when_only_missing_sc(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert "100 free API calls, no credit card" in q["nudge_text"]
+330
View File
@@ -0,0 +1,330 @@
"""Tests for scripts/lib/reddit_public.py — standalone Reddit public JSON search."""
import json
import urllib.error
from unittest import mock
import pytest
import sys
import os
# Ensure lib is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
from lib import reddit_public
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _make_reddit_listing(posts):
"""Build a Reddit listing JSON structure from a list of post dicts."""
children = []
for p in posts:
children.append({
"kind": "t3",
"data": {
"title": p.get("title", "Test Post"),
"permalink": p.get("permalink", "/r/test/comments/abc123/test_post/"),
"subreddit": p.get("subreddit", "test"),
"score": p.get("score", 42),
"num_comments": p.get("num_comments", 10),
"created_utc": p.get("created_utc", 1711670400), # 2024-03-29
"author": p.get("author", "testuser"),
"selftext": p.get("selftext", "Some body text"),
"upvote_ratio": p.get("upvote_ratio", 0.95),
},
})
return {"data": {"children": children}}
SAMPLE_LISTING = _make_reddit_listing([
{
"title": "Claude Code is amazing",
"permalink": "/r/ClaudeAI/comments/abc123/claude_code_is_amazing/",
"subreddit": "ClaudeAI",
"score": 250,
"num_comments": 45,
"created_utc": 1711670400,
"author": "ai_fan",
"selftext": "I've been using Claude Code for a week and it changed my workflow.",
},
{
"title": "Tips for Claude Code prompting",
"permalink": "/r/ClaudeAI/comments/def456/tips_for_claude_code/",
"subreddit": "ClaudeAI",
"score": 120,
"num_comments": 22,
"created_utc": 1711584000,
"author": "prompt_engineer",
"selftext": "Here are my top tips for getting the most out of Claude Code.",
},
])
def _mock_urlopen_ok(listing_data):
"""Return a context-manager mock for urllib.request.urlopen that returns listing_data."""
resp = mock.MagicMock()
resp.read.return_value = json.dumps(listing_data).encode("utf-8")
resp.headers = {"Content-Type": "application/json"}
resp.__enter__ = mock.MagicMock(return_value=resp)
resp.__exit__ = mock.MagicMock(return_value=False)
return resp
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSearchReturnsCorrectFields:
"""Search query returns parsed results with correct fields."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_search_returns_parsed_results(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
results = reddit_public.search("Claude Code", depth="quick")
assert len(results) == 2
first = results[0]
# Check all required fields exist
assert "id" in first
assert "title" in first
assert "url" in first
assert "score" in first
assert "num_comments" in first
assert "subreddit" in first
assert "created_utc" in first
assert "author" in first
assert "selftext" in first
# Check values
assert first["title"] == "Claude Code is amazing"
assert first["subreddit"] == "ClaudeAI"
assert first["score"] == 250
assert first["num_comments"] == 45
assert first["author"] == "ai_fan"
assert "/comments/" in first["url"]
assert first["id"] == "R1"
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_search_includes_normalized_fields(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
results = reddit_public.search("Claude Code")
first = results[0]
# Normalized fields matching ScrapeCreators format
assert "date" in first
assert "engagement" in first
assert "relevance" in first
assert "why_relevant" in first
assert isinstance(first["engagement"], dict)
assert "score" in first["engagement"]
assert "num_comments" in first["engagement"]
class TestSubredditScopedSearch:
"""Subreddit-scoped search works."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_subreddit_search_builds_correct_url(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
reddit_public.search("Claude Code", subreddit="ClaudeAI")
# Check the URL passed to urlopen
call_args = mock_urlopen.call_args
req = call_args[0][0] # First positional arg is the Request object
assert "/r/ClaudeAI/search.json" in req.full_url
assert "restrict_sr=on" in req.full_url
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_subreddit_search_strips_prefix(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
reddit_public.search("Claude Code", subreddit="r/ClaudeAI")
req = mock_urlopen.call_args[0][0]
# Should strip the r/ prefix, not double it
assert "/r/ClaudeAI/search.json" in req.full_url
assert "/r/r/" not in req.full_url
class TestRetryOn429:
"""429 response triggers retries, eventually returns partial results."""
@mock.patch("lib.reddit_public.time.sleep")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_429_retries_then_returns_empty(self, mock_urlopen, mock_sleep):
error = urllib.error.HTTPError(
"https://reddit.com/search.json", 429, "Too Many Requests",
{"Retry-After": "1"}, None,
)
mock_urlopen.side_effect = error
results = reddit_public.search("test query")
assert results == []
# Should have retried (MAX_RETRIES = 3, sleeps happen between attempts)
assert mock_sleep.call_count == reddit_public.MAX_RETRIES - 1
@mock.patch("lib.reddit_public.time.sleep")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_429_then_success(self, mock_urlopen, mock_sleep):
error = urllib.error.HTTPError(
"https://reddit.com/search.json", 429, "Too Many Requests",
{}, None,
)
success_resp = _mock_urlopen_ok(SAMPLE_LISTING)
mock_urlopen.side_effect = [error, success_resp]
results = reddit_public.search("test query")
assert len(results) == 2
assert mock_sleep.call_count == 1
class TestHtmlAntiBot:
"""HTML response (anti-bot) is detected, returns empty."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_html_response_returns_empty(self, mock_urlopen):
resp = mock.MagicMock()
resp.read.return_value = b"<html><body>Please verify you are human</body></html>"
resp.headers = {"Content-Type": "text/html; charset=utf-8"}
resp.__enter__ = mock.MagicMock(return_value=resp)
resp.__exit__ = mock.MagicMock(return_value=False)
mock_urlopen.return_value = resp
results = reddit_public.search("test query")
assert results == []
class TestNetworkTimeout:
"""Network timeout returns empty."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_timeout_returns_empty(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError("Connection timed out")
results = reddit_public.search("test query")
assert results == []
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_url_error_returns_empty(self, mock_urlopen):
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
results = reddit_public.search("test query")
assert results == []
class TestNormalizationMatchesScrapeCreators:
"""Results normalize to same schema as ScrapeCreators (field names match)."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_field_names_match_scrapecreators(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
results = reddit_public.search("Claude Code")
assert len(results) > 0
item = results[0]
# These fields must exist to match ScrapeCreators _normalize_post output
sc_required_fields = {"id", "title", "url", "subreddit", "date",
"engagement", "relevance", "why_relevant"}
actual_fields = set(item.keys())
assert sc_required_fields.issubset(actual_fields), (
f"Missing fields: {sc_required_fields - actual_fields}"
)
# Engagement sub-fields
eng = item["engagement"]
assert "score" in eng
assert "num_comments" in eng
assert "upvote_ratio" in eng
class TestDepthLimits:
"""Depth-aware limits are respected."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_quick_limit(self, mock_urlopen):
# Create more posts than the quick limit
many_posts = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/"}
for i in range(20)
])
mock_urlopen.return_value = _mock_urlopen_ok(many_posts)
results = reddit_public.search("test", depth="quick")
assert len(results) <= 10
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_default_limit(self, mock_urlopen):
many_posts = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/"}
for i in range(50)
])
mock_urlopen.return_value = _mock_urlopen_ok(many_posts)
results = reddit_public.search("test", depth="default")
assert len(results) <= 25
class TestSearchRedditPublicHighLevel:
"""Test the high-level search_reddit_public interface."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_date_filtering(self, mock_urlopen):
listing = _make_reddit_listing([
{
"title": "In range",
"permalink": "/r/test/comments/aaa/in_range/",
"created_utc": 1711670400, # 2024-03-29
},
{
"title": "Out of range",
"permalink": "/r/test/comments/bbb/out_of_range/",
"created_utc": 1609459200, # 2021-01-01
},
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
results = reddit_public.search_reddit_public(
"test", "2024-03-01", "2024-03-31"
)
titles = [r["title"] for r in results]
assert "In range" in titles
assert "Out of range" not in titles
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_user_agent_header(self, mock_urlopen):
mock_urlopen.return_value = _mock_urlopen_ok(SAMPLE_LISTING)
reddit_public.search("test")
req = mock_urlopen.call_args[0][0]
assert req.get_header("User-agent") == "last30days/3.0 (research tool)"
class TestMissingSubreddit:
"""Subreddit doesn't exist returns empty."""
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_404_subreddit_returns_empty(self, mock_urlopen):
mock_urlopen.side_effect = urllib.error.HTTPError(
"https://reddit.com/r/nonexistent/search.json",
404, "Not Found", {}, None,
)
results = reddit_public.search("test", subreddit="nonexistent")
assert results == []
+219
View File
@@ -0,0 +1,219 @@
"""Tests for Safari binary cookie extraction."""
from __future__ import annotations
import struct
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
# Import the internal parser directly for testability (avoids platform check)
from scripts.lib.safari_cookies import (
_parse_binary_cookies,
extract_safari_cookies_macos,
)
def _build_cookie_record(url: str, name: str, value: str, path: str = "/") -> bytes:
"""Build a single binary cookie record."""
# Fixed header: size(4) + flags(4) + padding(8) + url_off(4) + name_off(4) + path_off(4) + value_off(4) + comment(8) + expiry(8) + creation(8)
# Total fixed = 4 + 4 + 8 + 4 + 4 + 4 + 4 + 8 + 8 + 8 = 56 bytes
# String data starts at offset 56
url_b = url.encode("utf-8") + b"\x00"
name_b = name.encode("utf-8") + b"\x00"
path_b = path.encode("utf-8") + b"\x00"
value_b = value.encode("utf-8") + b"\x00"
str_offset_base = 56
url_offset = str_offset_base
name_offset = url_offset + len(url_b)
path_offset = name_offset + len(name_b)
value_offset = path_offset + len(path_b)
total_size = value_offset + len(value_b)
record = struct.pack("<I", total_size) # size
record += struct.pack("<I", 0) # flags
record += b"\x00" * 8 # padding/unknown
record += struct.pack("<I", url_offset) # url offset
record += struct.pack("<I", name_offset) # name offset
record += struct.pack("<I", path_offset) # path offset
record += struct.pack("<I", value_offset) # value offset
record += b"\x00" * 8 # comment (unused)
record += struct.pack("<d", 700000000.0) # expiry (Mac epoch)
record += struct.pack("<d", 690000000.0) # creation (Mac epoch)
record += url_b + name_b + path_b + value_b
return record
def _build_page(cookie_records: list[bytes]) -> bytes:
"""Build a binary cookies page from a list of cookie records."""
num_cookies = len(cookie_records)
# Page header: 4-byte marker + 4-byte cookie count + offset array
header_size = 4 + 4 + num_cookies * 4
# Also add 4 bytes for end-of-page marker
offsets_start = header_size
# Calculate offsets for each cookie record
offsets = []
current_offset = offsets_start
for rec in cookie_records:
offsets.append(current_offset)
current_offset += len(rec)
page = b"\x00\x00\x01\x00" # page header marker
page += struct.pack("<I", num_cookies)
for off in offsets:
page += struct.pack("<I", off)
for rec in cookie_records:
page += rec
return page
def _build_binary_cookies_file(pages: list[bytes]) -> bytes:
"""Build a complete Cookies.binarycookies file from pages."""
num_pages = len(pages)
data = b"cook" # magic
data += struct.pack(">I", num_pages) # page count (big-endian)
# Page sizes (big-endian)
for page in pages:
data += struct.pack(">I", len(page))
# Page data
for page in pages:
data += page
return data
@pytest.fixture
def x_cookies_file() -> bytes:
"""Build a minimal valid binary cookies file with .x.com cookies."""
rec1 = _build_cookie_record(".x.com", "auth_token", "test_auth_abc123")
rec2 = _build_cookie_record(".x.com", "ct0", "test_ct0_xyz789")
rec3 = _build_cookie_record(".google.com", "NID", "google_nid_value")
page = _build_page([rec1, rec2, rec3])
return _build_binary_cookies_file([page])
class TestParseValidCookies:
def test_extracts_matching_cookies(self, x_cookies_file: bytes):
result = _parse_binary_cookies(x_cookies_file, "x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == "test_auth_abc123"
assert result["ct0"] == "test_ct0_xyz789"
def test_ignores_other_domains(self, x_cookies_file: bytes):
result = _parse_binary_cookies(x_cookies_file, "google.com", ["auth_token"])
assert result is None
def test_partial_match_returns_found_only(self, x_cookies_file: bytes):
result = _parse_binary_cookies(
x_cookies_file, "x.com", ["auth_token", "nonexistent"]
)
assert result is not None
assert result["auth_token"] == "test_auth_abc123"
assert "nonexistent" not in result
def test_no_matching_cookie_names(self, x_cookies_file: bytes):
result = _parse_binary_cookies(x_cookies_file, "x.com", ["bogus"])
assert result is None
def test_domain_substring_match_with_leading_dot(self, x_cookies_file: bytes):
"""Domain '.x.com' in cookie should match search for 'x.com'."""
result = _parse_binary_cookies(x_cookies_file, "x.com", ["auth_token"])
assert result is not None
assert result["auth_token"] == "test_auth_abc123"
class TestMultiplePages:
def test_cookies_across_pages(self):
rec1 = _build_cookie_record(".x.com", "auth_token", "page1_auth")
rec2 = _build_cookie_record(".x.com", "ct0", "page2_ct0")
page1 = _build_page([rec1])
page2 = _build_page([rec2])
data = _build_binary_cookies_file([page1, page2])
result = _parse_binary_cookies(data, "x.com", ["auth_token", "ct0"])
assert result is not None
assert result["auth_token"] == "page1_auth"
assert result["ct0"] == "page2_ct0"
class TestErrorPaths:
def test_file_not_found(self, tmp_path: Path):
with patch(
"scripts.lib.safari_cookies.Path.home", return_value=tmp_path
), patch("scripts.lib.safari_cookies.sys") as mock_sys:
mock_sys.platform = "darwin"
mock_sys.stderr = sys.stderr
result = extract_safari_cookies_macos("x.com", ["auth_token"])
assert result is None
def test_permission_denied(self, tmp_path: Path, capsys):
cookie_dir = tmp_path / "Library" / "Cookies"
cookie_dir.mkdir(parents=True)
cookie_file = cookie_dir / "Cookies.binarycookies"
cookie_file.write_bytes(b"cook")
cookie_file.chmod(0o000)
try:
with patch(
"scripts.lib.safari_cookies.Path.home", return_value=tmp_path
), patch("scripts.lib.safari_cookies.sys") as mock_sys:
mock_sys.platform = "darwin"
mock_sys.stderr = sys.stderr
result = extract_safari_cookies_macos("x.com", ["auth_token"])
assert result is None
captured = capsys.readouterr()
assert "Full Disk Access" in captured.err
finally:
cookie_file.chmod(0o644)
def test_truncated_magic_only(self):
result = _parse_binary_cookies(b"cook", "x.com", ["auth_token"])
assert result is None
def test_empty_file(self):
result = _parse_binary_cookies(b"", "x.com", ["auth_token"])
assert result is None
def test_wrong_magic(self):
result = _parse_binary_cookies(b"notcook!", "x.com", ["auth_token"])
assert result is None
def test_truncated_page_sizes(self):
# Header says 5 pages but data is too short
data = b"cook" + struct.pack(">I", 5) + b"\x00" * 4
result = _parse_binary_cookies(data, "x.com", ["auth_token"])
assert result is None
def test_truncated_page_data(self):
# Valid header with 1 page of size 1000, but no actual page data
data = b"cook" + struct.pack(">I", 1) + struct.pack(">I", 1000)
result = _parse_binary_cookies(data, "x.com", ["auth_token"])
assert result is None
def test_non_darwin_returns_none(self):
with patch("scripts.lib.safari_cookies.sys") as mock_sys:
mock_sys.platform = "linux"
result = extract_safari_cookies_macos("x.com", ["auth_token"])
assert result is None
def test_garbage_data_no_crash(self):
"""Random bytes after valid magic should not crash."""
import os
data = b"cook" + os.urandom(200)
# Should not raise — may return None or a dict
result = _parse_binary_cookies(data, "x.com", ["auth_token"])
# Just verify no exception; result is either None or dict
assert result is None or isinstance(result, dict)
+342
View File
@@ -0,0 +1,342 @@
"""Tests for the first-run setup wizard module."""
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# Add scripts dir to path
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from lib import setup_wizard
class TestIsFirstRun:
"""Tests for is_first_run()."""
def test_first_run_when_setup_complete_not_set(self):
"""SETUP_COMPLETE not in config -> first run."""
config = {"AUTH_TOKEN": "abc", "CT0": "xyz"}
assert setup_wizard.is_first_run(config) is True
def test_first_run_when_setup_complete_is_none(self):
"""SETUP_COMPLETE=None -> first run."""
config = {"SETUP_COMPLETE": None}
assert setup_wizard.is_first_run(config) is True
def test_first_run_when_setup_complete_is_empty(self):
"""SETUP_COMPLETE="" -> first run."""
config = {"SETUP_COMPLETE": ""}
assert setup_wizard.is_first_run(config) is True
def test_not_first_run_when_setup_complete_true(self):
"""SETUP_COMPLETE=true -> not first run."""
config = {"SETUP_COMPLETE": "true"}
assert setup_wizard.is_first_run(config) is False
def test_not_first_run_when_setup_complete_any_value(self):
"""SETUP_COMPLETE set to any truthy value -> not first run."""
config = {"SETUP_COMPLETE": "yes"}
assert setup_wizard.is_first_run(config) is False
class TestRunAutoSetup:
"""Tests for run_auto_setup()."""
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_cookies_found(self, mock_which, mock_extract):
"""When cookies are found, results dict includes them."""
mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
mock_which.return_value = "/usr/local/bin/yt-dlp"
config = {}
results = setup_wizard.run_auto_setup(config)
assert "x" in results["cookies_found"]
assert results["cookies_found"]["x"] == "chrome"
assert results["ytdlp_installed"] is True
assert results["ytdlp_action"] == "already_installed"
assert results["env_written"] is False
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_no_cookies_found(self, mock_which, mock_extract):
"""When no cookies found, results dict has empty cookies_found."""
mock_extract.return_value = None
mock_which.return_value = None
config = {}
results = setup_wizard.run_auto_setup(config)
assert results["cookies_found"] == {}
assert results["ytdlp_installed"] is False
assert results["ytdlp_action"] == "no_homebrew"
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_cookie_extraction_exception(self, mock_which, mock_extract):
"""Cookie extraction raising an exception is handled gracefully."""
mock_extract.side_effect = Exception("DB locked")
mock_which.return_value = None
config = {}
results = setup_wizard.run_auto_setup(config)
assert results["cookies_found"] == {}
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_multiple_sources(self, mock_which, mock_extract):
"""Multiple cookie sources can be found."""
def side_effect(browser, domain, cookie_names):
if domain == ".x.com":
return ({"auth_token": "abc", "ct0": "xyz"}, "firefox")
elif domain == ".truthsocial.com":
return ({"_session_id": "sess123"}, "firefox")
return None
mock_extract.side_effect = side_effect
mock_which.return_value = None
config = {}
results = setup_wizard.run_auto_setup(config)
assert results["cookies_found"]["x"] == "firefox"
assert results["cookies_found"]["truthsocial"] == "firefox"
class TestYtdlpAutoInstall:
"""Tests for yt-dlp auto-install via Homebrew in run_auto_setup()."""
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
@patch("subprocess.run")
@patch("shutil.which")
def test_ytdlp_missing_brew_available_installs(self, mock_which, mock_subproc, mock_extract):
"""yt-dlp missing + brew available -> installs via brew."""
def which_side_effect(cmd):
if cmd == "yt-dlp":
return None
if cmd == "brew":
return "/opt/homebrew/bin/brew"
return None
mock_which.side_effect = which_side_effect
mock_subproc.return_value = MagicMock(returncode=0, stderr="")
results = setup_wizard.run_auto_setup({})
mock_subproc.assert_called_once_with(
["brew", "install", "yt-dlp"],
capture_output=True, text=True, timeout=120,
)
assert results["ytdlp_installed"] is True
assert results["ytdlp_action"] == "installed"
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
@patch("shutil.which")
def test_ytdlp_missing_brew_missing(self, mock_which, mock_extract):
"""yt-dlp missing + brew missing -> no_homebrew."""
mock_which.return_value = None
results = setup_wizard.run_auto_setup({})
assert results["ytdlp_installed"] is False
assert results["ytdlp_action"] == "no_homebrew"
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
@patch("shutil.which")
def test_ytdlp_already_installed(self, mock_which, mock_extract):
"""yt-dlp already installed -> already_installed."""
mock_which.return_value = "/usr/local/bin/yt-dlp"
results = setup_wizard.run_auto_setup({})
assert results["ytdlp_installed"] is True
assert results["ytdlp_action"] == "already_installed"
@patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
@patch("subprocess.run")
@patch("shutil.which")
def test_brew_install_fails(self, mock_which, mock_subproc, mock_extract):
"""brew install yt-dlp fails -> install_failed with stderr."""
def which_side_effect(cmd):
if cmd == "yt-dlp":
return None
if cmd == "brew":
return "/opt/homebrew/bin/brew"
return None
mock_which.side_effect = which_side_effect
mock_subproc.return_value = MagicMock(returncode=1, stderr="Error: something broke")
results = setup_wizard.run_auto_setup({})
assert results["ytdlp_installed"] is False
assert results["ytdlp_action"] == "install_failed"
assert "something broke" in results["ytdlp_stderr"]
class TestWriteSetupConfig:
"""Tests for write_setup_config()."""
def test_creates_new_env_file(self):
"""Creates .env file with SETUP_COMPLETE and FROM_BROWSER."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / "subdir" / ".env"
result = setup_wizard.write_setup_config(env_path)
assert result is True
assert env_path.exists()
content = env_path.read_text()
assert "SETUP_COMPLETE=true" in content
assert "FROM_BROWSER=auto" in content
def test_appends_to_existing_file(self):
"""Appends to existing .env without overwriting keys."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
env_path.write_text("XAI_API_KEY=my-key\nAUTH_TOKEN=tok123\n")
result = setup_wizard.write_setup_config(env_path)
assert result is True
content = env_path.read_text()
# Original keys preserved
assert "XAI_API_KEY=my-key" in content
assert "AUTH_TOKEN=tok123" in content
# New keys appended
assert "SETUP_COMPLETE=true" in content
assert "FROM_BROWSER=auto" in content
def test_does_not_overwrite_existing_keys(self):
"""If SETUP_COMPLETE or FROM_BROWSER already exist, don't duplicate."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
result = setup_wizard.write_setup_config(env_path)
assert result is True
content = env_path.read_text()
# Should only appear once
assert content.count("SETUP_COMPLETE") == 1
assert content.count("FROM_BROWSER") == 1
# Original value preserved
assert "FROM_BROWSER=firefox" in content
def test_custom_from_browser_value(self):
"""Custom from_browser value is written."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
result = setup_wizard.write_setup_config(env_path, from_browser="chrome")
assert result is True
content = env_path.read_text()
assert "FROM_BROWSER=chrome" in content
def test_creates_parent_directories(self):
"""Creates parent directories if they don't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / "a" / "b" / "c" / ".env"
result = setup_wizard.write_setup_config(env_path)
assert result is True
assert env_path.exists()
def test_handles_file_without_trailing_newline(self):
"""Appends correctly when existing file has no trailing newline."""
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
env_path.write_text("EXISTING_KEY=value") # no trailing newline
result = setup_wizard.write_setup_config(env_path)
assert result is True
content = env_path.read_text()
# Should have newline separator
lines = content.strip().split("\n")
assert len(lines) == 3
assert lines[0] == "EXISTING_KEY=value"
assert "SETUP_COMPLETE=true" in lines[1]
class TestGetSetupStatusText:
"""Tests for get_setup_status_text()."""
def test_with_cookies_and_ytdlp(self):
"""Status text mentions found cookies and yt-dlp."""
results = {
"cookies_found": {"x": "chrome"},
"ytdlp_installed": True,
"ytdlp_action": "already_installed",
"env_written": True,
}
text = setup_wizard.get_setup_status_text(results)
assert "X cookies found in chrome" in text
assert "yt-dlp already installed" in text
assert "Configuration saved" in text
def test_with_no_cookies_no_ytdlp(self):
"""Status text shows no cookies and suggests yt-dlp install."""
results = {
"cookies_found": {},
"ytdlp_installed": False,
"ytdlp_action": "no_homebrew",
"env_written": False,
}
text = setup_wizard.get_setup_status_text(results)
assert "No browser cookies found" in text
assert "Install Homebrew first" in text
def test_status_text_installed(self):
"""Status text for freshly installed yt-dlp."""
results = {
"cookies_found": {},
"ytdlp_installed": True,
"ytdlp_action": "installed",
"env_written": False,
}
text = setup_wizard.get_setup_status_text(results)
assert "Installed yt-dlp via Homebrew" in text
def test_status_text_install_failed(self):
"""Status text for failed yt-dlp install."""
results = {
"cookies_found": {},
"ytdlp_installed": False,
"ytdlp_action": "install_failed",
"env_written": False,
}
text = setup_wizard.get_setup_status_text(results)
assert "yt-dlp install failed" in text
assert "manually" in text
class TestSetupSubcommand:
"""Tests for setup subcommand detection in argument parsing."""
def test_setup_detected_as_topic(self):
"""The word 'setup' is treated as the setup subcommand."""
# Simulate what argparse produces
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("topic", nargs="*")
args = parser.parse_args(["setup"])
topic = " ".join(args.topic) if args.topic else None
assert topic is not None
assert topic.strip().lower() == "setup"
def test_normal_topic_not_setup(self):
"""A normal topic is not confused with setup."""
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("topic", nargs="*")
args = parser.parse_args(["AI", "video", "tools"])
topic = " ".join(args.topic) if args.topic else None
assert topic.strip().lower() != "setup"
+670
View File
@@ -0,0 +1,670 @@
"""Tests for source resolution priority hierarchy (Unit 4).
Validates the free-first priority chain:
env AUTH_TOKEN/CT0 -> browser cookies -> XAI_API_KEY -> None
"""
import os
from unittest.mock import patch, MagicMock
import pytest
from scripts.lib import env
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict with typical defaults."""
cfg = {
"AUTH_TOKEN": None,
"CT0": None,
"XAI_API_KEY": None,
"SCRAPECREATORS_API_KEY": None,
"OPENAI_API_KEY": None,
"OPENAI_AUTH_STATUS": "missing",
"OPENROUTER_API_KEY": None,
"PARALLEL_API_KEY": None,
"BRAVE_API_KEY": None,
"BSKY_HANDLE": None,
"BSKY_APP_PASSWORD": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": None,
"SETUP_COMPLETE": None,
"_AUTH_TOKEN_SOURCE": None,
}
cfg.update(overrides)
return cfg
def _mock_bird_installed(installed=True):
"""Patch bird_x.is_bird_installed to return the given value."""
return patch("scripts.lib.bird_x.is_bird_installed", return_value=installed)
def _mock_bird_authenticated(username=None):
"""Patch bird_x.is_bird_authenticated to return the given value."""
return patch("scripts.lib.bird_x.is_bird_authenticated", return_value=username)
def _mock_bird_status(installed=True, authenticated=True, username="env AUTH_TOKEN"):
"""Patch bird_x.get_bird_status to return a status dict."""
return patch("scripts.lib.bird_x.get_bird_status", return_value={
"installed": installed,
"authenticated": authenticated,
"username": username,
"can_install": True,
})
# ---------------------------------------------------------------------------
# Tests: X source resolution priority
# ---------------------------------------------------------------------------
class TestXSourcePriority:
"""Test the X source priority chain: env -> browser cookies -> xAI -> None."""
def test_no_env_cookies_found_resolves_bird_browser(self):
"""No env vars, SETUP_COMPLETE=true, cookies found -> Bird with browser method."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-firefox"
def test_env_auth_token_plus_cookies_env_wins(self):
"""AUTH_TOKEN in .env + cookies available -> env wins (method='env')."""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="env",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "env"
def test_no_env_no_cookies_xai_key_resolves_xai(self):
"""No env vars, no cookies, XAI_API_KEY set -> xAI with method 'api'."""
config = _base_config(XAI_API_KEY="xai-key-123")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_no_env_no_cookies_no_api_keys_none(self):
"""No env vars, no cookies, no API keys -> X not available."""
config = _base_config()
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source is None
assert method is None
def test_bird_not_installed_falls_to_xai(self):
"""Bird not installed, XAI_API_KEY set -> xAI."""
config = _base_config(XAI_API_KEY="xai-key")
with _mock_bird_installed(False), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_bird_not_installed_no_xai_none(self):
"""Bird not installed, no XAI_API_KEY -> None."""
config = _base_config()
with _mock_bird_installed(False), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source is None
assert method is None
def test_browser_chrome_method_tracked(self):
"""Cookies from Chrome -> method is 'browser-chrome'."""
config = _base_config(
AUTH_TOKEN="chrome_tok",
CT0="chrome_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-chrome"
def test_browser_safari_method_tracked(self):
"""Cookies from Safari -> method is 'browser-safari'."""
config = _base_config(
AUTH_TOKEN="safari_tok",
CT0="safari_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-safari",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-safari"
# ---------------------------------------------------------------------------
# Tests: get_x_source() backward compat
# ---------------------------------------------------------------------------
class TestGetXSourceBackwardCompat:
"""Ensure get_x_source() returns the same string as before."""
def test_bird_returns_bird(self):
config = _base_config(AUTH_TOKEN="tok", CT0="ct0", _AUTH_TOKEN_SOURCE="env")
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
assert env.get_x_source(config) == "bird"
def test_xai_returns_xai(self):
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
assert env.get_x_source(config) == "xai"
def test_none_returns_none(self):
config = _base_config()
with _mock_bird_installed(False):
assert env.get_x_source(config) is None
# ---------------------------------------------------------------------------
# Tests: get_x_source_status() method field
# ---------------------------------------------------------------------------
class TestGetXSourceStatusMethod:
"""Test that get_x_source_status() includes the method field."""
def test_bird_env_method(self):
config = _base_config(AUTH_TOKEN="tok", CT0="ct0", _AUTH_TOKEN_SOURCE="env")
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"), \
_mock_bird_status(installed=True, authenticated=True, username="env AUTH_TOKEN"):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "env"
assert "bird_installed" in status
assert "xai_available" in status
def test_bird_browser_method(self):
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"), \
_mock_bird_status(installed=True, authenticated=True):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "browser-firefox"
def test_xai_api_method(self):
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None), \
_mock_bird_status(installed=True, authenticated=False, username=None):
status = env.get_x_source_status(config)
assert status["source"] == "xai"
assert status["method"] == "api"
def test_no_source_method_none(self):
config = _base_config()
with _mock_bird_installed(False), \
_mock_bird_status(installed=False, authenticated=False, username=None):
status = env.get_x_source_status(config)
assert status["source"] is None
assert status["method"] is None
# ---------------------------------------------------------------------------
# Tests: get_available_sources() with various configs
# ---------------------------------------------------------------------------
class TestGetAvailableSources:
"""Test get_available_sources() returns correct strings."""
def test_no_x_no_web_reddit_only(self):
"""No X source, no web keys -> 'reddit' (Reddit always available)."""
config = _base_config()
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result == "reddit"
def test_xai_key_no_web(self):
"""XAI_API_KEY set, no web keys -> 'both'."""
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
result = env.get_available_sources(config)
assert result == "both"
def test_bird_auth_no_web(self):
"""Bird authenticated (cookies), SETUP_COMPLETE=true, no web keys -> 'both'."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
result = env.get_available_sources(config)
assert result == "both"
def test_bird_auth_with_web(self):
"""Bird authenticated + web keys -> 'all'."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
BRAVE_API_KEY="brave-key",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
result = env.get_available_sources(config)
assert result == "all"
def test_no_x_with_web(self):
"""No X source, web keys -> 'reddit-web'."""
config = _base_config(BRAVE_API_KEY="brave-key")
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result == "reddit-web"
def test_reddit_hn_polymarket_always_available(self):
"""Reddit, HN, and Polymarket are always available regardless of config."""
config = _base_config()
# These functions don't depend on config
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
# Reddit: get_available_sources always includes it
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result in ("reddit", "reddit-web") # never 'none' when Reddit is always True
# ---------------------------------------------------------------------------
# Tests: Full resolution with mixed config
# ---------------------------------------------------------------------------
class TestFullResolution:
"""Test that each source resolves independently with mixed config."""
def test_mixed_config_all_sources(self):
"""Bird for X, public Reddit, web keys, YouTube/TikTok available."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
BRAVE_API_KEY="brave-key",
SCRAPECREATORS_API_KEY="sc-key",
BSKY_HANDLE="user.bsky.social",
BSKY_APP_PASSWORD="app-pw",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
x_source, x_method = env.get_x_source_with_method(config)
available = env.get_available_sources(config)
assert x_source == "bird"
assert x_method == "browser-chrome"
assert available == "all" # reddit + x + web
assert env.is_bluesky_available(config) is True
assert env.is_tiktok_available(config) is True
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
def test_no_config_minimal_sources(self):
"""No API keys, no cookies -> Reddit + HN + Polymarket only."""
config = _base_config()
with _mock_bird_installed(False):
x_source = env.get_x_source(config)
available = env.get_available_sources(config)
assert x_source is None
assert available == "reddit"
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
assert env.is_bluesky_available(config) is False
assert env.is_tiktok_available(config) is False
# ---------------------------------------------------------------------------
# Tests: extract_browser_credentials tracks browser source
# ---------------------------------------------------------------------------
class TestExtractBrowserCredentialsSource:
"""Test that extract_browser_credentials tracks __X_BROWSER."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_tracks_firefox_source(self, mock_extract):
"""Cookies from Firefox -> __X_BROWSER set to 'firefox'."""
mock_extract.return_value = (
{"auth_token": "tok", "ct0": "ct0val"},
"firefox",
)
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "auto",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "tok"
assert result["CT0"] == "ct0val"
assert result["__X_BROWSER"] == "firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_tracks_chrome_source(self, mock_extract):
"""Cookies from Chrome -> __X_BROWSER set to 'chrome'."""
mock_extract.return_value = (
{"auth_token": "tok", "ct0": "ct0val"},
"chrome",
)
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "chrome",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert result["__X_BROWSER"] == "chrome"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_no_cookies_no_browser_key(self, mock_extract):
"""No cookies found -> no __X_BROWSER key."""
mock_extract.return_value = None
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "auto",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert "__X_BROWSER" not in result
# ---------------------------------------------------------------------------
# Tests: get_config() _AUTH_TOKEN_SOURCE tracking
# ---------------------------------------------------------------------------
class TestGetConfigAuthTokenSource:
"""Test that get_config() sets _AUTH_TOKEN_SOURCE correctly."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_env_var_auth_token_source_env(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""AUTH_TOKEN from env var -> _AUTH_TOKEN_SOURCE='env'."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = None
env_patch = {
"AUTH_TOKEN": "env_token",
"CT0": "env_ct0",
"LAST30DAYS_CONFIG_DIR": "",
}
with patch.dict(os.environ, env_patch, clear=False):
config = get_config()
assert config["_AUTH_TOKEN_SOURCE"] == "env"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_browser_cookies_auth_token_source_browser(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""AUTH_TOKEN from cookies -> _AUTH_TOKEN_SOURCE='browser-firefox'."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = (
{"auth_token": "cookie_tok", "ct0": "cookie_ct0"},
"firefox",
)
env_patch = {
"SETUP_COMPLETE": "true",
"FROM_BROWSER": "auto",
"LAST30DAYS_CONFIG_DIR": "",
}
# Ensure AUTH_TOKEN is NOT in env
clean_env = {k: v for k, v in os.environ.items()
if k not in ("AUTH_TOKEN", "CT0")}
clean_env.update(env_patch)
with patch.dict(os.environ, clean_env, clear=True):
config = get_config()
assert config["AUTH_TOKEN"] == "cookie_tok"
assert config["_AUTH_TOKEN_SOURCE"] == "browser-firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_no_auth_token_source_none(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""No AUTH_TOKEN at all -> _AUTH_TOKEN_SOURCE=None."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = None
clean_env = {k: v for k, v in os.environ.items()
if k not in ("AUTH_TOKEN", "CT0")}
clean_env["LAST30DAYS_CONFIG_DIR"] = ""
with patch.dict(os.environ, clean_env, clear=True):
config = get_config()
assert config["_AUTH_TOKEN_SOURCE"] is None
# ---------------------------------------------------------------------------
# Tests: SETUP_COMPLETE gate — Bird cookie probing blocked before consent
# ---------------------------------------------------------------------------
class TestSetupCompleteGate:
"""Test that Bird cookie probing is gated behind SETUP_COMPLETE consent."""
def test_no_setup_complete_bird_has_cookies_returns_none(self):
"""SETUP_COMPLETE not set, Bird has browser cookies -> returns None (not 'bird').
Bird's is_bird_authenticated() should NOT be called at all because
there is no consent yet. Cookie-sourced AUTH_TOKEN must be ignored.
"""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE=None, # not set — first run
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), \
_mock_bird_authenticated("Chrome") as mock_auth:
source = env.get_x_source(config)
assert source is None
# is_bird_authenticated should NOT have been called (no cookie probing)
mock_auth.assert_not_called()
def test_setup_complete_bird_has_cookies_returns_bird(self):
"""SETUP_COMPLETE=true, Bird has browser cookies -> returns 'bird'.
After user consent, cookie probing should work normally.
"""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), _mock_bird_authenticated("Chrome"):
source = env.get_x_source(config)
assert source == "bird"
def test_no_setup_complete_explicit_auth_token_returns_bird(self):
"""SETUP_COMPLETE not set, AUTH_TOKEN from env var -> returns 'bird' with method 'env'.
Explicit env var credentials must ALWAYS work regardless of SETUP_COMPLETE.
"""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE=None, # not set
_AUTH_TOKEN_SOURCE="env",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "env"
def test_no_setup_complete_xai_key_returns_xai(self):
"""SETUP_COMPLETE not set, XAI_API_KEY configured -> returns 'xai'.
API keys always work without setup consent.
"""
config = _base_config(
XAI_API_KEY="xai-key-123",
SETUP_COMPLETE=None, # not set
)
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_no_setup_complete_status_reports_not_configured(self):
"""First-run status banner should show X as not configured when only cookies exist."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE=None,
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True):
status = env.get_x_source_status(config)
assert status["source"] is None
assert status["method"] is None
assert status["bird_authenticated"] is False
def test_setup_complete_status_reports_bird(self):
"""After consent, status banner should show Bird as configured."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("Firefox"), \
_mock_bird_status(installed=True, authenticated=True, username="Firefox"):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "browser-firefox"
assert status["bird_authenticated"] is True
# ---------------------------------------------------------------------------
# Tests: BIRD_DISABLE_BROWSER_COOKIES env var on first run
# ---------------------------------------------------------------------------
class TestBirdDisableBrowserCookiesEnvVar:
"""Test that BIRD_DISABLE_BROWSER_COOKIES is set to block Bird's Node.js
sweet-cookie scanner on first run before user consent."""
def _run_main_flow_env_setup(self, config, first_run):
"""Simulate the env-var-setting logic from last30days.py main flow.
Mirrors the block right after first_run detection in main().
"""
if first_run and config.get('_AUTH_TOKEN_SOURCE') != 'env':
os.environ['BIRD_DISABLE_BROWSER_COOKIES'] = '1'
else:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_first_run_no_auth_token_sets_env_var(self):
"""first_run=True, no AUTH_TOKEN -> BIRD_DISABLE_BROWSER_COOKIES is set."""
config = _base_config(SETUP_COMPLETE=None, _AUTH_TOKEN_SOURCE=None)
try:
self._run_main_flow_env_setup(config, first_run=True)
assert os.environ.get('BIRD_DISABLE_BROWSER_COOKIES') == '1'
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_not_first_run_no_env_var(self):
"""first_run=False -> BIRD_DISABLE_BROWSER_COOKIES is NOT set."""
config = _base_config(SETUP_COMPLETE="true", _AUTH_TOKEN_SOURCE="browser-firefox")
try:
self._run_main_flow_env_setup(config, first_run=False)
assert 'BIRD_DISABLE_BROWSER_COOKIES' not in os.environ
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_first_run_explicit_auth_token_no_env_var(self):
"""first_run=True, AUTH_TOKEN explicitly set -> BIRD_DISABLE_BROWSER_COOKIES is NOT set."""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE=None,
_AUTH_TOKEN_SOURCE="env",
)
try:
self._run_main_flow_env_setup(config, first_run=True)
assert 'BIRD_DISABLE_BROWSER_COOKIES' not in os.environ
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
+283
View File
@@ -0,0 +1,283 @@
"""Tests for the redesigned status banner (free-first design)."""
import pytest
from scripts.lib.ui import _build_status_banner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_diag(**overrides):
"""Return a minimal diag dict with common defaults."""
diag = {
"setup_complete": False,
"reddit_source": None, # None = public fallback
"x_source": None,
"x_method": None,
"youtube": False,
"tiktok": False,
"instagram": False,
"hackernews": True,
"polymarket": True,
"bluesky": False,
"truthsocial": False,
"xiaohongshu": False,
"scrapecreators": False,
"web_search_backend": None,
}
diag.update(overrides)
return diag
def _banner_text(diag):
"""Return full banner as a single string."""
return "\n".join(_build_status_banner(diag))
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestZeroConfig:
"""Zero-config state: SETUP_COMPLETE not set, wizard hasn't run."""
def test_shows_first_run_title(self):
banner = _banner_text(_base_diag())
assert "First Run" in banner
def test_shows_three_free_sources(self):
banner = _banner_text(_base_diag())
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
def test_shows_setup_prompt(self):
banner = _banner_text(_base_diag())
assert "/last30days setup" in banner
def test_does_not_show_source_status_title(self):
banner = _banner_text(_base_diag())
assert "Source Status" not in banner
class TestFullConfig:
"""Fully configured: X + yt-dlp + ScrapeCreators = everything active."""
def _full_diag(self):
return _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
hackernews=True,
polymarket=True,
bluesky=True,
truthsocial=True,
xiaohongshu=True,
scrapecreators=True,
web_search_backend="parallel",
)
def test_shows_source_status_title(self):
banner = _banner_text(self._full_diag())
assert "Source Status" in banner
def test_shows_all_sources(self):
banner = _banner_text(self._full_diag())
assert "Reddit (with comments)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "HN" in banner
assert "Polymarket" in banner
assert "TikTok" in banner
assert "Instagram" in banner
assert "Bluesky" in banner
assert "Truth Social" in banner
assert "Xiaohongshu" in banner
def test_no_recommendations(self):
banner = _banner_text(self._full_diag())
assert "" not in banner
assert "scrapecreators.com" not in banner
assert "/last30days setup" not in banner
class TestPartialConfig:
"""Partially configured: X + yt-dlp but no ScrapeCreators."""
def _partial_diag(self):
return _base_diag(
setup_complete=True,
reddit_source=None, # public fallback
x_source="bird",
x_method="browser-chrome",
youtube=True,
scrapecreators=False,
)
def test_shows_active_sources(self):
banner = _banner_text(self._partial_diag())
assert "Reddit (threads only)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "HN" in banner
assert "Polymarket" in banner
def test_recommends_scrapecreators(self):
banner = _banner_text(self._partial_diag())
assert "SCRAPECREATORS_API_KEY" in banner
def test_scrapecreators_free_calls_copy(self):
banner = _banner_text(self._partial_diag())
assert "100 free calls, no CC" in banner
assert "scrapecreators.com" in banner
def test_shows_tiktok_instagram_unlock(self):
banner = _banner_text(self._partial_diag())
assert "TikTok" in banner
assert "Instagram" in banner
class TestScrapeCreatorsRecommendation:
"""ScrapeCreators recommendation always includes key copy."""
def test_always_includes_free_calls_no_cc(self):
"""Any config missing SC should show the free-calls copy."""
# Zero config
banner_zero = _banner_text(_base_diag())
# Zero config doesn't recommend SC directly (recommends setup wizard)
# But after setup, missing SC should always include the copy
banner_partial = _banner_text(_base_diag(
setup_complete=True,
scrapecreators=False,
))
assert "100 free calls, no CC" in banner_partial
def test_present_when_x_available_but_no_sc(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="xai",
x_method="api",
scrapecreators=False,
))
assert "100 free calls, no CC" in banner
assert "scrapecreators.com" in banner
class TestRedditLabelDisplay:
"""Reddit label reflects comment availability, not implementation details."""
def test_no_scrapecreators_shows_threads_only(self):
"""Without SC, Reddit label should say 'threads only' regardless of OpenAI auth."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source="openai",
scrapecreators=False,
))
assert "Reddit (threads only)" in banner
assert "OpenAI" not in banner
assert "Codex" not in banner
def test_no_scrapecreators_public_shows_threads_only(self):
"""Public fallback also shows 'threads only'."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source=None,
scrapecreators=False,
))
assert "Reddit (threads only)" in banner
def test_scrapecreators_shows_with_comments(self):
"""With SC configured, Reddit label should say 'with comments'."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source="scrapecreators",
scrapecreators=True,
))
assert "Reddit (with comments)" in banner
def test_no_openai_or_codex_in_banner(self):
"""Banner should never mention OpenAI or Codex — those are implementation details."""
for source in [None, "openai", "scrapecreators"]:
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source=source,
scrapecreators=(source == "scrapecreators"),
))
assert "OpenAI" not in banner, f"Found 'OpenAI' with reddit_source={source}"
assert "Codex" not in banner, f"Found 'Codex' with reddit_source={source}"
class TestXMethodDisplay:
"""X source shows auth method in parens."""
def test_browser_chrome(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
))
assert "X (Chrome)" in banner
def test_browser_firefox(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-firefox",
))
assert "X (Firefox)" in banner
def test_env_method(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="env",
))
assert "X (env)" in banner
def test_xai_api(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="xai",
x_method="api",
))
assert "X (xAI)" in banner
class TestBannerStructure:
"""Banner formatting and structure tests."""
def test_has_box_drawing(self):
lines = _build_status_banner(_base_diag())
assert lines[0].startswith("")
assert lines[-1].startswith("")
def test_shows_config_path(self):
banner = _banner_text(_base_diag())
assert "~/.config/last30days/.env" in banner
def test_max_10_inner_lines(self):
"""Banner should be compact — max 10 lines inside the box."""
# Full config (most lines)
diag = _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
bluesky=True,
truthsocial=True,
xiaohongshu=True,
scrapecreators=True,
)
lines = _build_status_banner(diag)
# Subtract top and bottom border
inner_lines = [l for l in lines if l.startswith("")]
assert len(inner_lines) <= 10, f"Banner has {len(inner_lines)} inner lines, max is 10"
+138
View File
@@ -1,8 +1,10 @@
"""Tests for yt-dlp invocation safety flags."""
import json
import sys
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest import mock
@@ -37,6 +39,7 @@ class TestYtDlpFlags(unittest.TestCase):
def test_transcript_fetch_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with tempfile.TemporaryDirectory() as temp_dir, \
mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \
mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock:
youtube_yt.fetch_transcript("abc123", temp_dir)
@@ -76,5 +79,140 @@ class TestExtractTranscriptHighlights(unittest.TestCase):
self.assertEqual(len(highlights), 3)
class TestFetchTranscriptDirect(unittest.TestCase):
"""Tests for _fetch_transcript_direct() — direct HTTP transcript fetching."""
# Minimal ytInitialPlayerResponse JSON with a caption track
_PLAYER_RESPONSE = json.dumps({
"captions": {
"playerCaptionsTracklistRenderer": {
"captionTracks": [
{
"baseUrl": "https://www.youtube.com/api/timedtext?v=abc123&lang=en",
"languageCode": "en",
}
]
}
}
})
_WATCH_HTML = (
'<html><script>var ytInitialPlayerResponse = '
+ _PLAYER_RESPONSE
+ ';</script></html>'
)
_SAMPLE_VTT = (
"WEBVTT\n\n"
"00:00:00.000 --> 00:00:02.000\n"
"Hello world this is a test sentence with enough words to pass.\n\n"
"00:00:02.000 --> 00:00:04.000\n"
"Another line of transcript text here for testing purposes.\n"
)
def _mock_urlopen(self, url_or_req, *, timeout=None):
"""Return watch HTML or VTT depending on URL."""
url = url_or_req.full_url if hasattr(url_or_req, 'full_url') else url_or_req
class _Resp:
def __init__(self, data):
self._data = data.encode("utf-8")
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
pass
if "watch?" in url:
return _Resp(self._WATCH_HTML)
elif "timedtext" in url:
return _Resp(self._SAMPLE_VTT)
raise urllib.error.URLError("unexpected URL")
def test_extracts_vtt_from_mock_page(self):
"""Happy path: extracts VTT text from a page with captions."""
with mock.patch("lib.youtube_yt.urllib.request.urlopen", side_effect=self._mock_urlopen):
result = youtube_yt._fetch_transcript_direct("abc123")
self.assertIsNotNone(result)
self.assertIn("WEBVTT", result)
self.assertIn("Hello world", result)
def test_no_captions_returns_none(self):
"""Video with no caption tracks returns None."""
no_captions_response = json.dumps({"captions": {"playerCaptionsTracklistRenderer": {"captionTracks": []}}})
html = f'<html><script>var ytInitialPlayerResponse = {no_captions_response};</script></html>'
class _Resp:
def __init__(self, data):
self._data = data.encode("utf-8")
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
pass
def mock_open(req, *, timeout=None):
return _Resp(html)
with mock.patch("lib.youtube_yt.urllib.request.urlopen", side_effect=mock_open):
result = youtube_yt._fetch_transcript_direct("nocaps")
self.assertIsNone(result)
def test_http_timeout_returns_none(self):
"""HTTP timeout on watch page returns None."""
def timeout_open(req, *, timeout=None):
raise TimeoutError("timed out")
with mock.patch("lib.youtube_yt.urllib.request.urlopen", side_effect=timeout_open):
result = youtube_yt._fetch_transcript_direct("timeout_vid")
self.assertIsNone(result)
def test_direct_vtt_feeds_into_clean_vtt(self):
"""VTT from direct fetch produces clean plaintext via _clean_vtt()."""
cleaned = youtube_yt._clean_vtt(self._SAMPLE_VTT)
self.assertNotIn("WEBVTT", cleaned)
self.assertNotIn("-->", cleaned)
self.assertIn("Hello world", cleaned)
self.assertIn("Another line", cleaned)
class TestFetchTranscriptFallback(unittest.TestCase):
"""Tests that fetch_transcript picks yt-dlp or direct path correctly."""
def test_uses_ytdlp_when_installed(self):
"""When yt-dlp is installed, uses _fetch_transcript_ytdlp."""
with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \
mock.patch.object(youtube_yt, "_fetch_transcript_ytdlp", return_value="WEBVTT\n\nfake") as yt_mock, \
mock.patch.object(youtube_yt, "_fetch_transcript_direct") as direct_mock:
result = youtube_yt.fetch_transcript("vid1", "/tmp/test")
yt_mock.assert_called_once_with("vid1", "/tmp/test")
direct_mock.assert_not_called()
def test_uses_direct_when_ytdlp_missing(self):
"""When yt-dlp is NOT installed, falls back to _fetch_transcript_direct."""
sample_vtt = (
"WEBVTT\n\n"
"00:00:00.000 --> 00:00:02.000\n"
"Direct transcript content with enough words for testing.\n"
)
with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=False), \
mock.patch.object(youtube_yt, "_fetch_transcript_ytdlp") as yt_mock, \
mock.patch.object(youtube_yt, "_fetch_transcript_direct", return_value=sample_vtt) as direct_mock:
result = youtube_yt.fetch_transcript("vid2", "/tmp/test")
yt_mock.assert_not_called()
direct_mock.assert_called_once_with("vid2")
self.assertIsNotNone(result)
self.assertIn("Direct transcript content", result)
def test_returns_none_when_both_fail(self):
"""Returns None when the chosen path returns None."""
with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=False), \
mock.patch.object(youtube_yt, "_fetch_transcript_direct", return_value=None):
result = youtube_yt.fetch_transcript("novid", "/tmp/test")
self.assertIsNone(result)
if __name__ == "__main__":
unittest.main()