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:
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
@@ -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 == ""
|
||||
@@ -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"]
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user