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
215 lines
7.8 KiB
Python
215 lines
7.8 KiB
Python
"""Tests for ``notebooklm._app.profile`` — the profile-management core.
|
|
|
|
Covers the Click-free profile logic backing ``profile list`` / ``delete`` /
|
|
``switch`` / ``rename``:
|
|
|
|
* :func:`gather_profile_list` — name → active/authenticated/account rows, with
|
|
the profile/storage/account collaborators injected (no ``notebooklm.paths`` /
|
|
``notebooklm.auth`` reach-in); the empty-profiles case; account-email coercion.
|
|
* :func:`is_protected_profile` — the active/configured-default delete guard.
|
|
* :func:`set_default_profile_mutator` / :func:`retarget_default_profile_mutator`
|
|
— the ``config.json`` mutator closures, the "missing default ⇒ 'default'" rule,
|
|
the ``was_updated`` predicate, and corrupt-config recovery (the mutator runs
|
|
under the adapter's lock against whatever dict it is handed).
|
|
|
|
Direct ``_app`` calls only — collaborators injected as plain callables /
|
|
``MagicMock``s, no Click / CliRunner.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from notebooklm._app.profile import (
|
|
ProfileEntry,
|
|
gather_profile_list,
|
|
is_protected_profile,
|
|
retarget_default_profile_mutator,
|
|
set_default_profile_mutator,
|
|
)
|
|
|
|
|
|
def _fake_storage(*, exists: bool) -> MagicMock:
|
|
storage = MagicMock(spec=Path)
|
|
storage.exists.return_value = exists
|
|
return storage
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# gather_profile_list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_gather_profile_list_builds_rows() -> None:
|
|
storages = {
|
|
"default": _fake_storage(exists=True),
|
|
"work": _fake_storage(exists=False),
|
|
}
|
|
accounts = {
|
|
"default": {"email": "me@example.com"},
|
|
"work": {},
|
|
}
|
|
|
|
def _get_storage_path(*, profile: str) -> Path:
|
|
return storages[profile]
|
|
|
|
def _read_account_metadata(path: Path) -> dict[str, Any]:
|
|
# Look up by identity to map the injected storage back to its account.
|
|
for name, storage in storages.items():
|
|
if storage is path:
|
|
return accounts[name]
|
|
raise AssertionError("unexpected storage path")
|
|
|
|
entries, active = gather_profile_list(
|
|
list_profiles=lambda: ["default", "work"],
|
|
resolve_profile=lambda: "default",
|
|
get_storage_path=_get_storage_path,
|
|
read_account_metadata=_read_account_metadata,
|
|
)
|
|
|
|
assert active == "default"
|
|
assert entries == [
|
|
ProfileEntry(name="default", active=True, authenticated=True, account="me@example.com"),
|
|
ProfileEntry(name="work", active=False, authenticated=False, account=None),
|
|
]
|
|
|
|
|
|
def test_gather_profile_list_empty() -> None:
|
|
"""No profiles → empty rows but the active name still resolves."""
|
|
entries, active = gather_profile_list(
|
|
list_profiles=lambda: [],
|
|
resolve_profile=lambda: "default",
|
|
get_storage_path=lambda *, profile: _fake_storage(exists=False),
|
|
read_account_metadata=lambda path: {},
|
|
)
|
|
|
|
assert entries == []
|
|
assert active == "default"
|
|
|
|
|
|
def test_gather_profile_list_coerces_non_str_email_to_none() -> None:
|
|
"""A non-string ``email`` field is coerced to ``None`` (defensive)."""
|
|
entries, _ = gather_profile_list(
|
|
list_profiles=lambda: ["default"],
|
|
resolve_profile=lambda: "default",
|
|
get_storage_path=lambda *, profile: _fake_storage(exists=True),
|
|
read_account_metadata=lambda path: {"email": 12345},
|
|
)
|
|
|
|
assert entries[0].account is None
|
|
|
|
|
|
def test_gather_profile_list_get_storage_called_per_profile() -> None:
|
|
get_storage_path = MagicMock(side_effect=lambda *, profile: _fake_storage(exists=True))
|
|
|
|
gather_profile_list(
|
|
list_profiles=lambda: ["a", "b", "c"],
|
|
resolve_profile=lambda: "a",
|
|
get_storage_path=get_storage_path,
|
|
read_account_metadata=lambda path: {},
|
|
)
|
|
|
|
assert get_storage_path.call_count == 3
|
|
get_storage_path.assert_any_call(profile="a")
|
|
get_storage_path.assert_any_call(profile="c")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_protected_profile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_protected_profile_blocks_active() -> None:
|
|
assert (
|
|
is_protected_profile("work", configured_default="default", effective_active="work") is True
|
|
)
|
|
|
|
|
|
def test_is_protected_profile_blocks_configured_default() -> None:
|
|
assert (
|
|
is_protected_profile("default", configured_default="default", effective_active="work")
|
|
is True
|
|
)
|
|
|
|
|
|
def test_is_protected_profile_allows_other() -> None:
|
|
assert (
|
|
is_protected_profile("old", configured_default="default", effective_active="work") is False
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set_default_profile_mutator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_set_default_profile_mutator_sets_key() -> None:
|
|
mutator = set_default_profile_mutator("work")
|
|
out = mutator({"default_profile": "default", "other": 1})
|
|
assert out["default_profile"] == "work"
|
|
# Other keys are preserved.
|
|
assert out["other"] == 1
|
|
|
|
|
|
def test_set_default_profile_mutator_on_empty_config() -> None:
|
|
mutator = set_default_profile_mutator("work")
|
|
assert mutator({}) == {"default_profile": "work"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# retarget_default_profile_mutator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_retarget_rewrites_matching_default() -> None:
|
|
mutator, was_updated = retarget_default_profile_mutator(old_name="work", new_name="work-old")
|
|
out = mutator({"default_profile": "work"})
|
|
assert out["default_profile"] == "work-old"
|
|
assert was_updated() is True
|
|
|
|
|
|
def test_retarget_missing_default_treated_as_default() -> None:
|
|
"""A config with no ``default_profile`` key implies the literal 'default'."""
|
|
mutator, was_updated = retarget_default_profile_mutator(old_name="default", new_name="primary")
|
|
out = mutator({}) # missing key ⇒ implicit "default"
|
|
assert out["default_profile"] == "primary"
|
|
assert was_updated() is True
|
|
|
|
|
|
def test_retarget_blank_default_treated_as_default() -> None:
|
|
"""An empty-string ``default_profile`` is also treated as 'default'."""
|
|
mutator, was_updated = retarget_default_profile_mutator(old_name="default", new_name="primary")
|
|
out = mutator({"default_profile": ""})
|
|
assert out["default_profile"] == "primary"
|
|
assert was_updated() is True
|
|
|
|
|
|
def test_retarget_noop_when_default_is_other() -> None:
|
|
"""Renaming a non-default profile leaves the config + predicate untouched."""
|
|
mutator, was_updated = retarget_default_profile_mutator(old_name="work", new_name="work-old")
|
|
out = mutator({"default_profile": "primary"})
|
|
assert out["default_profile"] == "primary"
|
|
assert was_updated() is False
|
|
|
|
|
|
def test_retarget_was_updated_false_before_run() -> None:
|
|
"""The predicate reports False until the mutator has actually fired."""
|
|
_mutator, was_updated = retarget_default_profile_mutator(old_name="work", new_name="w2")
|
|
assert was_updated() is False
|
|
|
|
|
|
def test_retarget_recovers_against_corrupt_recovered_dict() -> None:
|
|
"""Under corrupt-config recovery the adapter hands the mutator a fresh ``{}``.
|
|
|
|
The locked writer recovers a corrupt config to an empty dict before applying
|
|
the mutator; the missing-key ⇒ 'default' rule then still retargets a renamed
|
|
'default' profile.
|
|
"""
|
|
mutator, was_updated = retarget_default_profile_mutator(old_name="default", new_name="renamed")
|
|
recovered: dict[str, Any] = {} # what _atomic_write_config(recover_from_corrupt=True) passes
|
|
out = mutator(recovered)
|
|
assert out["default_profile"] == "renamed"
|
|
assert was_updated() is True
|