09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Tests for migration from legacy flat layout to profile-based structure."""
|
|
|
|
import json
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from notebooklm.migration import ensure_profiles_dir, migrate_to_profiles
|
|
from notebooklm.paths import _reset_config_cache, set_active_profile
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_profile_state():
|
|
"""Reset module-level profile state between tests."""
|
|
set_active_profile(None)
|
|
_reset_config_cache()
|
|
yield
|
|
set_active_profile(None)
|
|
_reset_config_cache()
|
|
|
|
|
|
def _clean_env():
|
|
env = os.environ.copy()
|
|
env.pop("NOTEBOOKLM_HOME", None)
|
|
env.pop("NOTEBOOKLM_PROFILE", None)
|
|
return env
|
|
|
|
|
|
class TestMigrateToProfiles:
|
|
def test_already_migrated(self, tmp_path):
|
|
"""Returns False if profiles/ already exists."""
|
|
(tmp_path / "profiles" / "default").mkdir(parents=True)
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
assert migrate_to_profiles() is False
|
|
|
|
def test_fresh_install(self, tmp_path):
|
|
"""Creates profiles/ dir on fresh install (no legacy files)."""
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
assert migrate_to_profiles() is True
|
|
assert (tmp_path / "profiles").exists()
|
|
|
|
def test_migrates_legacy_files(self, tmp_path):
|
|
"""Moves legacy files into profiles/default/."""
|
|
# Create legacy layout
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
(tmp_path / "context.json").write_text('{"notebook_id":"nb1"}')
|
|
(tmp_path / "browser_profile").mkdir()
|
|
(tmp_path / "browser_profile" / "data").write_text("chrome data")
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
assert migrate_to_profiles() is True
|
|
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
# Files moved to profile dir
|
|
assert (default_dir / "storage_state.json").exists()
|
|
assert json.loads((default_dir / "storage_state.json").read_text()) == {"cookies": []}
|
|
assert (default_dir / "context.json").exists()
|
|
assert (default_dir / "browser_profile" / "data").exists()
|
|
# Migration marker present
|
|
assert (default_dir / ".migration_complete").exists()
|
|
# Originals removed
|
|
assert not (tmp_path / "storage_state.json").exists()
|
|
assert not (tmp_path / "context.json").exists()
|
|
assert not (tmp_path / "browser_profile").exists()
|
|
|
|
def test_updates_config(self, tmp_path):
|
|
"""Sets default_profile in config.json."""
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
migrate_to_profiles()
|
|
|
|
config = json.loads((tmp_path / "config.json").read_text())
|
|
assert config["default_profile"] == "default"
|
|
|
|
def test_preserves_existing_config(self, tmp_path):
|
|
"""Preserves existing config.json values during migration."""
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
(tmp_path / "config.json").write_text(json.dumps({"language": "ja"}))
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
migrate_to_profiles()
|
|
|
|
config = json.loads((tmp_path / "config.json").read_text())
|
|
assert config["language"] == "ja"
|
|
assert config["default_profile"] == "default"
|
|
|
|
def test_idempotent(self, tmp_path):
|
|
"""Running twice is safe — second call is no-op."""
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
assert migrate_to_profiles() is True
|
|
assert migrate_to_profiles() is False # Already migrated
|
|
|
|
def test_partial_legacy(self, tmp_path):
|
|
"""Works with only some legacy files present."""
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
# No context.json or browser_profile
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
assert migrate_to_profiles() is True
|
|
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
assert (default_dir / "storage_state.json").exists()
|
|
assert not (default_dir / "context.json").exists()
|
|
|
|
def test_interrupted_migration_retries(self, tmp_path):
|
|
"""Interrupted migration (marker + leftover legacy files) is retried."""
|
|
# Simulate crash after marker but before deletion (old buggy order)
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
default_dir.mkdir(parents=True)
|
|
(default_dir / "storage_state.json").write_text('{"cookies":[]}')
|
|
(default_dir / ".migration_complete").write_text("migrated\n")
|
|
# Legacy file still at root — this simulates a crash
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
# ensure_profiles_dir should detect leftover legacy files and re-migrate
|
|
ensure_profiles_dir()
|
|
|
|
# Legacy file should now be cleaned up
|
|
assert not (tmp_path / "storage_state.json").exists()
|
|
assert (default_dir / "storage_state.json").exists()
|
|
|
|
def test_marker_written_after_deletion(self, tmp_path):
|
|
"""Marker is written only after originals are deleted."""
|
|
(tmp_path / "storage_state.json").write_text('{"cookies":[]}')
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
migrate_to_profiles()
|
|
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
# Marker exists
|
|
assert (default_dir / ".migration_complete").exists()
|
|
# Original removed
|
|
assert not (tmp_path / "storage_state.json").exists()
|
|
|
|
def test_overwrites_stale_profile_when_legacy_is_newer(self, tmp_path):
|
|
"""Fresh login data at legacy root overwrites a stale profile copy.
|
|
|
|
Regression for the scenario where a previous migration left a profile
|
|
copy in place, and a subsequent `login` wrote fresh cookies to the
|
|
legacy root path. The migration must prefer the newer source.
|
|
"""
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
default_dir.mkdir(parents=True)
|
|
stale_profile = default_dir / "storage_state.json"
|
|
stale_profile.write_text('{"cookies":["stale"]}')
|
|
os.utime(stale_profile, (1000, 1000))
|
|
|
|
fresh_legacy = tmp_path / "storage_state.json"
|
|
fresh_legacy.write_text('{"cookies":["fresh"]}')
|
|
os.utime(fresh_legacy, (2000, 2000))
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
migrate_to_profiles()
|
|
|
|
# Profile copy now holds the fresh data, legacy file is gone
|
|
assert json.loads(stale_profile.read_text()) == {"cookies": ["fresh"]}
|
|
assert not fresh_legacy.exists()
|
|
|
|
def test_keeps_newer_profile_when_legacy_is_older(self, tmp_path):
|
|
"""An up-to-date profile copy is preserved over an older legacy file."""
|
|
default_dir = tmp_path / "profiles" / "default"
|
|
default_dir.mkdir(parents=True)
|
|
fresh_profile = default_dir / "storage_state.json"
|
|
fresh_profile.write_text('{"cookies":["fresh"]}')
|
|
os.utime(fresh_profile, (2000, 2000))
|
|
|
|
stale_legacy = tmp_path / "storage_state.json"
|
|
stale_legacy.write_text('{"cookies":["stale"]}')
|
|
os.utime(stale_legacy, (1000, 1000))
|
|
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
migrate_to_profiles()
|
|
|
|
# Profile copy is preserved, legacy file is cleaned up
|
|
assert json.loads(fresh_profile.read_text()) == {"cookies": ["fresh"]}
|
|
assert not stale_legacy.exists()
|
|
|
|
|
|
class TestEnsureProfilesDir:
|
|
def test_creates_on_first_run(self, tmp_path):
|
|
"""Creates profiles directory on first invocation."""
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
ensure_profiles_dir()
|
|
assert (tmp_path / "profiles").exists()
|
|
|
|
def test_noop_when_exists(self, tmp_path):
|
|
"""Does nothing if profiles/ already exists."""
|
|
(tmp_path / "profiles").mkdir()
|
|
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
|
|
ensure_profiles_dir() # Should not raise
|
|
assert (tmp_path / "profiles").exists()
|
|
|
|
|
|
class TestExplicitAuthBypassesProfileSetup:
|
|
"""Verify that --storage and NOTEBOOKLM_AUTH_JSON don't require writable home."""
|
|
|
|
def test_storage_flag_skips_profile_setup(self, tmp_path):
|
|
"""CLI with --storage should not call ensure_profiles_dir."""
|
|
from click.testing import CliRunner
|
|
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
# Make home read-only to prove profiles dir creation is skipped
|
|
ro_home = tmp_path / "ro_home"
|
|
ro_home.mkdir(mode=0o500)
|
|
|
|
storage_file = tmp_path / "storage.json"
|
|
storage_file.write_text('{"cookies": []}')
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli,
|
|
["--storage", str(storage_file), "status", "--paths"],
|
|
env={"NOTEBOOKLM_HOME": str(ro_home)},
|
|
)
|
|
# Should not crash with PermissionError from profiles dir creation
|
|
assert result.exit_code != 1 or "PermissionError" not in str(result.exception or "")
|
|
|
|
def test_auth_json_env_skips_profile_setup(self, tmp_path):
|
|
"""CLI with NOTEBOOKLM_AUTH_JSON should not call ensure_profiles_dir."""
|
|
from click.testing import CliRunner
|
|
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
ro_home = tmp_path / "ro_home"
|
|
ro_home.mkdir(mode=0o500)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli,
|
|
["status", "--paths"],
|
|
env={
|
|
"NOTEBOOKLM_HOME": str(ro_home),
|
|
"NOTEBOOKLM_AUTH_JSON": '{"cookies": [{"name": "SID", "value": "x", "domain": ".google.com"}, {"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"}]}',
|
|
},
|
|
)
|
|
assert result.exit_code != 1 or "PermissionError" not in str(result.exception or "")
|