chore: import upstream snapshot with attribution
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:45 +08:00
commit 555e282cc4
1802 changed files with 338080 additions and 0 deletions
View File
+146
View File
@@ -0,0 +1,146 @@
"""Shared fixtures for mem0 CLI tests."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from mem0_cli.backend.base import Backend
from mem0_cli.config import Mem0Config
@pytest.fixture(autouse=True)
def isolate_config(tmp_path, monkeypatch):
"""Redirect config to a temp directory so tests don't touch real config."""
fake_config_dir = tmp_path / ".mem0"
fake_config_file = fake_config_dir / "config.json"
monkeypatch.setattr("mem0_cli.config.CONFIG_DIR", fake_config_dir)
monkeypatch.setattr("mem0_cli.config.CONFIG_FILE", fake_config_file)
# Also patch the commands that import config
monkeypatch.setattr("mem0_cli.commands.config_cmd.CONFIG_DIR", fake_config_dir, raising=False)
# Clear any MEM0 env vars
for key in list(os.environ.keys()):
if key.startswith("MEM0_"):
monkeypatch.delenv(key, raising=False)
return fake_config_dir
@pytest.fixture
def mock_backend():
"""Return a mock backend with all methods stubbed."""
backend = MagicMock(spec=Backend)
# Default return values
backend.add.return_value = {
"results": [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"event": "ADD",
}
]
}
backend.search.return_value = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"score": 0.92,
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"score": 0.78,
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
backend.get.return_value = {
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"created_at": "2026-02-15T10:30:00Z",
"updated_at": "2026-02-20T08:00:00Z",
"metadata": {"source": "onboarding"},
"categories": ["preferences"],
}
backend.list_memories.return_value = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
backend.update.return_value = {"id": "abc-123-def-456", "memory": "Updated memory"}
backend.delete.return_value = {"status": "deleted"}
backend.status.return_value = {
"connected": True,
"backend": "platform",
"base_url": "https://api.mem0.ai",
}
backend.delete_entities.return_value = {"message": "Entity deleted"}
backend.entities.return_value = [
{"name": "alice", "count": 5},
{"name": "bob", "count": 3},
]
backend.list_events.return_value = [
{
"id": "evt-abc-123-def-456",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": None,
"latency": 1234.5,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:01Z",
},
{
"id": "evt-def-456-ghi-789",
"event_type": "SEARCH",
"status": "PENDING",
"graph_status": None,
"latency": None,
"created_at": "2026-04-01T10:01:00Z",
"updated_at": "2026-04-01T10:01:00Z",
},
]
backend.get_event.return_value = {
"id": "evt-abc-123-def-456",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": "SUCCEEDED",
"latency": 1234.5,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:01Z",
"results": [
{
"id": "mem-abc-123",
"event": "ADD",
"user_id": "alice",
"data": {"memory": "User prefers dark mode"},
}
],
}
return backend
@pytest.fixture
def sample_config():
"""Return a sample config object."""
config = Mem0Config()
config.platform.api_key = "m0-test-key-12345678"
config.platform.base_url = "https://api.mem0.ai"
return config
+160
View File
@@ -0,0 +1,160 @@
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
sync so that the Python and Node CLIs expose an identical surface for the
Agent Mode entrypoint. If you add a flag here, add the same assertion on
the Node side (and vice versa).
Network-bound bootstrap is covered by the platform-side E2E suite
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
the CLI surface that ships in the binary.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
env["PYTHONIOENCODING"] = "utf-8"
if home_dir:
env["HOME"] = home_dir
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
encoding="utf-8",
env=env,
timeout=15,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
return str(tmp_path)
class TestInitFlagSurface:
"""`mem0 init --help` must expose the Agent Mode flags."""
def test_init_help_lists_agent_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--agent" in result.stdout
def test_init_help_describes_agent_mode(self):
result = _run(["init", "--help"])
assert result.returncode == 0
# Description must mention what --agent actually does so an agent
# reading the help can self-discover the bootstrap entrypoint.
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
def test_init_help_lists_source_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--source" in result.stdout
def test_init_help_lists_email_and_code(self):
# Claim flow flags must remain present alongside Agent Mode flags.
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--email" in result.stdout
assert "--code" in result.stdout
class TestArgvPreprocessing:
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
Regression for the bug where the top-level `--agent` JSON-alias was
stripped from ``sys.argv`` before Typer could bind it to the init
subcommand, making ``mem0 init --agent`` indistinguishable from a
plain ``mem0 init`` (interactive wizard).
"""
def test_init_with_agent_reaches_subcommand(self, clean_home):
# We can't hit a real backend in unit tests, so we point the CLI at
# a guaranteed-dead URL and assert the failure is the bootstrap
# request failing — proving the --agent flag was honored and the
# bootstrap branch ran, not the interactive wizard.
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
capture_output=True,
encoding="utf-8",
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
"FORCE_COLOR": "0",
"PYTHONIOENCODING": "utf-8",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr).lower()
# Either we got a connection/network error from the bootstrap POST,
# or the CLI surfaced an Agent Mode-specific failure message.
assert (
"agent" in combined
or "connect" in combined
or "network" in combined
or "fetch" in combined
or "bootstrap" in combined
), f"Expected bootstrap attempt, got: {combined!r}"
class TestJsonEnvelopeParity:
"""`mem0 init --agent --json` should produce a JSON envelope on success.
Without a live backend we can only assert the failure shape: when the
backend is unreachable, the CLI must still exit non-zero AND not crash
on a Python traceback (which would mean we leaked an exception past
the agent-mode handler).
"""
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
capture_output=True,
encoding="utf-8",
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1",
"FORCE_COLOR": "0",
"PYTHONIOENCODING": "utf-8",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr)
assert "Traceback (most recent call last)" not in combined
assert result.returncode != 0
class TestInitInCommandList:
"""`mem0 --help` must list `init` so agents walking the top-level help
can discover the Agent Mode entrypoint without prior knowledge."""
def test_top_level_help_lists_init(self):
result = _run(["--help"])
assert result.returncode == 0
assert "init" in result.stdout
+54
View File
@@ -0,0 +1,54 @@
"""Tests for branding and output helpers."""
from __future__ import annotations
from io import StringIO
from rich.console import Console
from mem0_cli.branding import print_banner, print_error, print_info, print_success, print_warning
def _make_console() -> tuple[Console, StringIO]:
buf = StringIO()
return Console(file=buf, force_terminal=False, no_color=True, width=80), buf
class TestBranding:
def test_print_banner(self):
console, buf = _make_console()
print_banner(console)
output = buf.getvalue()
# Banner contains the mem0 ASCII art and tagline
assert "Memory Layer" in output or "mem" in output.lower()
def test_print_success(self):
console, buf = _make_console()
print_success(console, "It worked!")
output = buf.getvalue()
assert "It worked!" in output
def test_print_error(self):
console, buf = _make_console()
print_error(console, "Something failed", hint="Try this fix")
output = buf.getvalue()
assert "Something failed" in output
assert "Try this fix" in output
def test_print_error_no_hint(self):
console, buf = _make_console()
print_error(console, "Failed")
output = buf.getvalue()
assert "Failed" in output
def test_print_warning(self):
console, buf = _make_console()
print_warning(console, "Watch out")
output = buf.getvalue()
assert "Watch out" in output
def test_print_info(self):
console, buf = _make_console()
print_info(console, "FYI")
output = buf.getvalue()
assert "FYI" in output
+248
View File
@@ -0,0 +1,248 @@
"""Integration tests — invoke CLI as subprocess to test end-to-end.
These tests launch the CLI as a real subprocess, so they must manage
environment isolation themselves (monkeypatch doesn't cross process
boundaries).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
return _ANSI_RE.sub("", text)
def _run(
args: list[str],
env_override: dict | None = None,
home_dir: str | None = None,
) -> subprocess.CompletedProcess:
"""Run mem0 CLI command and capture output.
Args:
args: CLI arguments.
env_override: Extra env vars to set.
home_dir: If provided, set HOME to this path so the subprocess
reads config from ``<home_dir>/.mem0/config.json`` instead
of the user's real config. This is critical for tests that
depend on a clean (no API key) or custom config state.
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
fragment option names like --user-id into separately-styled ANSI segments,
making plain ``in`` checks fail. Stripping here is version-agnostic and
ensures all assertions see the same plain text regardless of terminal env.
"""
env = os.environ.copy()
# Strip all MEM0_ env vars so tests start clean
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
env["PYTHONIOENCODING"] = "utf-8"
if home_dir:
env["HOME"] = home_dir
if env_override:
env.update(env_override)
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
encoding="utf-8",
env=env,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
"""Return a temp directory to use as HOME, ensuring no ~/.mem0 exists."""
return str(tmp_path)
class TestCLIIntegration:
"""Tests that only inspect help text / version — no config needed."""
def test_help(self):
result = _run(["--help"])
assert result.returncode == 0
assert "mem0" in result.stdout
assert "add" in result.stdout
assert "search" in result.stdout
def test_add_help(self):
result = _run(["add", "--help"])
assert result.returncode == 0
assert "user-id" in result.stdout
assert "messages" in result.stdout
def test_add_help_has_scope_panel(self):
"""Verify rich_help_panel grouping shows in help output."""
result = _run(["add", "--help"])
assert result.returncode == 0
assert "Scope" in result.stdout
def test_search_help(self):
result = _run(["search", "--help"])
assert result.returncode == 0
assert "top-k" in result.stdout
def test_list_help(self):
result = _run(["list", "--help"])
assert result.returncode == 0
assert "page-size" in result.stdout
def test_delete_help(self):
result = _run(["delete", "--help"])
assert result.returncode == 0
assert "--all" in result.stdout
assert "--entity" in result.stdout
assert "--project" in result.stdout
assert "--force" in result.stdout
assert "--dry-run" in result.stdout
def test_entity_list_help(self):
result = _run(["entity", "list", "--help"])
assert result.returncode == 0
assert "entity-type" in result.stdout.lower() or "entity_type" in result.stdout.lower()
def test_entity_delete_help(self):
result = _run(["entity", "delete", "--help"])
assert result.returncode == 0
assert "--user-id" in result.stdout
assert "--force" in result.stdout
def test_import_help(self):
result = _run(["import", "--help"])
assert result.returncode == 0
def test_no_args_shows_help(self):
"""no_args_is_help=True makes Typer print help and exit with code 2."""
result = _run([])
# Typer returns exit code 2 for "no command given" — this is standard
# Click/Typer behaviour and not an error.
assert result.returncode in (0, 2)
assert "Usage" in result.stdout
class TestCLIIsolated:
"""Tests that need a clean HOME to avoid reading the user's real config."""
def test_add_no_key_errors(self, clean_home):
"""Without an API key, `mem0 add` must fail with a helpful message."""
result = _run(
["add", "test", "--user-id", "alice"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "api" in combined.lower() or "Error" in combined
def test_search_no_key_errors(self, clean_home):
"""Without an API key, `mem0 search` must fail."""
result = _run(
["search", "preferences", "--user-id", "alice"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "Error" in combined
def test_list_no_key_errors(self, clean_home):
"""Without an API key, `mem0 list` must fail."""
result = _run(["list"], home_dir=clean_home)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "API key" in combined or "Error" in combined
def test_delete_no_id_no_all_errors(self, clean_home):
"""Delete without memory_id, --all, or --entity must fail."""
result = _run(
["delete", "--api-key", "m0-fake-key"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert (
"memory ID" in combined.lower()
or "--all" in combined
or "--entity" in combined
or "Error" in combined
)
def test_config_show_clean(self, clean_home):
"""config show with no config should still work."""
result = _run(["config", "show"], home_dir=clean_home)
assert result.returncode == 0
assert "backend" in result.stdout.lower() or "platform" in result.stdout.lower()
def test_config_set_and_get_roundtrip(self, clean_home):
"""config set then config get should return the set value."""
_run(
["config", "set", "defaults.user_id", "integration-test-user"],
home_dir=clean_home,
)
result = _run(
["config", "get", "defaults.user_id"],
home_dir=clean_home,
)
assert result.returncode == 0
assert "integration-test-user" in result.stdout
def test_import_nonexistent_file(self, clean_home):
"""Importing a nonexistent file should fail gracefully."""
result = _run(
["import", "/nonexistent/file.json", "--api-key", "m0-fake"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "Failed" in combined or "Error" in combined or "error" in combined
def test_add_no_content_errors(self, clean_home):
"""add with no text/messages/file should fail."""
result = _run(
["add", "--user-id", "alice", "--api-key", "m0-fake"],
home_dir=clean_home,
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert "No content" in combined or "Error" in combined
class TestCLINewFeatures:
"""Tests for MCP parity features: --limit, entities delete."""
def test_search_help_has_limit(self):
result = _run(["search", "--help"])
assert result.returncode == 0
assert "--limit" in result.stdout
def test_delete_entity_via_delete_flag(self):
"""delete --entity should appear in help output."""
result = _run(["delete", "--help"])
assert result.returncode == 0
assert "--entity" in result.stdout
def test_entity_delete_has_scope_options(self):
"""entity delete should expose scope options."""
result = _run(["entity", "delete", "--help"])
assert result.returncode == 0
assert "--user-id" in result.stdout
assert "--force" in result.stdout
assert "--app-id" in result.stdout
assert "--run-id" in result.stdout
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
"""Tests for configuration management."""
from __future__ import annotations
import os
from mem0_cli.config import (
Mem0Config,
get_nested_value,
load_config,
redact_key,
save_config,
set_nested_value,
)
class TestRedactKey:
def test_empty_key(self):
assert redact_key("") == "(not set)"
def test_short_key(self):
assert redact_key("abc") == "ab***"
def test_normal_key(self):
result = redact_key("m0-abcdefgh12345678")
assert result == "m0-a...5678"
assert "abcdefgh" not in result
def test_exact_8_chars(self):
# 8 chars is <= 8, so it gets the short redaction
assert redact_key("12345678") == "12***"
class TestConfig:
def test_default_config(self):
config = Mem0Config()
assert config.platform.base_url == "https://api.mem0.ai"
assert config.platform.api_key == ""
def test_save_and_load(self, isolate_config):
config = Mem0Config()
config.platform.api_key = "m0-test-key"
save_config(config)
loaded = load_config()
assert loaded.platform.api_key == "m0-test-key"
def test_env_var_override(self, isolate_config, monkeypatch):
config = Mem0Config()
config.platform.api_key = "file-key"
save_config(config)
monkeypatch.setenv("MEM0_API_KEY", "env-key")
loaded = load_config()
assert loaded.platform.api_key == "env-key"
def test_load_nonexistent_config(self, isolate_config):
config = load_config()
assert config.platform.api_key == ""
def test_config_file_permissions(self, isolate_config):
config = Mem0Config()
config.platform.api_key = "secret"
save_config(config)
from mem0_cli.config import CONFIG_FILE
mode = os.stat(CONFIG_FILE).st_mode & 0o777
if os.name != "nt":
assert mode == 0o600
def test_defaults_save_and_load(self, isolate_config):
config = Mem0Config()
config.defaults.user_id = "alice"
config.defaults.agent_id = "support-bot"
config.defaults.app_id = "my-app"
config.defaults.run_id = "run-001"
save_config(config)
loaded = load_config()
assert loaded.defaults.user_id == "alice"
assert loaded.defaults.agent_id == "support-bot"
assert loaded.defaults.app_id == "my-app"
assert loaded.defaults.run_id == "run-001"
def test_defaults_env_var_override(self, isolate_config, monkeypatch):
config = Mem0Config()
config.defaults.user_id = "file-user"
save_config(config)
monkeypatch.setenv("MEM0_USER_ID", "env-user")
monkeypatch.setenv("MEM0_AGENT_ID", "env-agent")
loaded = load_config()
assert loaded.defaults.user_id == "env-user"
assert loaded.defaults.agent_id == "env-agent"
def test_backward_compat_no_defaults_key(self, isolate_config):
"""Old config files without 'defaults' key should load fine."""
import json
from mem0_cli.config import CONFIG_FILE, ensure_config_dir
ensure_config_dir()
# Write a config without the "defaults" key
data = {
"version": 1,
"platform": {"api_key": "m0-test", "base_url": "https://api.mem0.ai"},
}
with open(CONFIG_FILE, "w") as f:
json.dump(data, f)
loaded = load_config()
assert loaded.platform.api_key == "m0-test"
assert loaded.defaults.user_id == ""
assert loaded.defaults.agent_id == ""
def test_default_config_has_empty_defaults(self):
config = Mem0Config()
assert config.defaults.user_id == ""
assert config.defaults.agent_id == ""
assert config.defaults.app_id == ""
assert config.defaults.run_id == ""
class TestNestedAccess:
def test_get_nested_value(self):
config = Mem0Config()
config.platform.api_key = "test-key"
assert get_nested_value(config, "platform.api_key") == "test-key"
def test_get_nonexistent_key(self):
config = Mem0Config()
assert get_nested_value(config, "nonexistent.key") is None
def test_set_nested_value(self):
config = Mem0Config()
assert set_nested_value(config, "platform.api_key", "new-key")
assert config.platform.api_key == "new-key"
def test_set_int_value_rejects_invalid_input(self):
config = Mem0Config()
assert set_nested_value(config, "version", "abc") is False
assert config.version == 1
def test_set_nonexistent_key(self):
config = Mem0Config()
assert set_nested_value(config, "nonexistent.key", "val") is False
def test_get_defaults_user_id(self):
config = Mem0Config()
config.defaults.user_id = "alice"
assert get_nested_value(config, "defaults.user_id") == "alice"
def test_set_defaults_user_id(self):
config = Mem0Config()
assert set_nested_value(config, "defaults.user_id", "bob")
assert config.defaults.user_id == "bob"
class TestResolveIds:
def test_cli_flag_overrides_default(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = "default-user"
ids = _resolve_ids(
config,
user_id="cli-user",
agent_id=None,
)
assert ids["user_id"] == "cli-user"
def test_default_used_when_flag_is_none(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = "default-user"
config.defaults.agent_id = "default-agent"
ids = _resolve_ids(config, user_id=None, agent_id=None)
assert ids["user_id"] == "default-user"
assert ids["agent_id"] == "default-agent"
def test_none_when_neither_set(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
ids = _resolve_ids(config, user_id=None, agent_id=None)
assert ids["user_id"] is None
assert ids["agent_id"] is None
assert ids["app_id"] is None
assert ids["run_id"] is None
def test_empty_string_treated_as_unset(self):
from mem0_cli.app import _resolve_ids
config = Mem0Config()
config.defaults.user_id = ""
ids = _resolve_ids(config, user_id=None)
assert ids["user_id"] is None
+206
View File
@@ -0,0 +1,206 @@
"""Unit tests for init internals — decision tree primitives + plugin sync.
These tests exercise the units that the high-level subprocess parity tests in
``test_agent_mode.py`` deliberately can't reach:
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
flap silently mints a new shadow over a working key).
- ``plugin_sync`` must only update entries that already exist, preserve
trailing newlines, and never mangle other lines.
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
real cause instead of DRF's opaque "You do not have permission" string.
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
behavioral assertion here, mirror it on the Node side and vice versa.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import pytest
from mem0_cli.commands.init_cmd import _ping_key
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
# ── _ping_key ──────────────────────────────────────────────────────────────
class _Resp:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
assert _ping_key("k", "http://x") is True
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
assert _ping_key("k", "http://x") is False
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
assert _ping_key("k", "http://x") is False
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
# Transient upstream failure must NOT cause a shadow to be minted.
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
assert _ping_key("k", "http://x") is True
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
def boom(*a, **kw):
raise httpx.ConnectError("nope")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(*a, **kw):
raise httpx.ReadTimeout("slow")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is True
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is False
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
rc = tmp_path / ".zshrc"
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
rc.write_text(original, encoding="utf-8")
_update_shell_rc(rc, "newkey")
after = rc.read_text(encoding="utf-8")
assert "alias ll='ls -la'\n" in after
assert "export OTHER=keepme\n" in after
assert "# my zshrc\n" in after
assert 'export MEM0_API_KEY="newkey"\n' in after
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
assert _update_shell_rc(rc, "same") is False
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
rc = tmp_path / ".zshrc" # does not exist
assert _update_shell_rc(rc, "x") is False
# ── plugin_sync._update_claude_settings ────────────────────────────────────
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
# Original content unchanged.
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
encoding="utf-8",
)
changed = _update_claude_settings(settings, "fresh")
assert changed is True
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["env"]["MEM0_API_KEY"] == "fresh"
assert data["env"]["OTHER"] == "y" # other keys preserved
def test_claude_settings_idempotent(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
assert _update_claude_settings(settings, "same") is False
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
settings = tmp_path / "settings.json"
settings.write_text("{ this is not json", encoding="utf-8")
assert _update_claude_settings(settings, "x") is False
# ── bootstrap rate-limit translation ──────────────────────────────────────
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
from mem0_cli.config import Mem0Config
fake_resp = MagicMock()
fake_resp.status_code = 403
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
fake_resp.json = MagicMock(
return_value={"detail": "You do not have permission to perform this action."}
)
class _Client:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *a, **kw):
return fake_resp
monkeypatch.setattr(httpx, "Client", _Client)
cfg = Mem0Config()
cfg.platform.base_url = "https://api.mem0.ai"
import typer
with pytest.raises(typer.Exit):
bootstrap_via_backend(cfg)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "Daily Agent Mode signup limit reached" in combined
assert "permission to perform this action" not in combined
+305
View File
@@ -0,0 +1,305 @@
"""Tests for output formatting."""
from __future__ import annotations
from io import StringIO
from rich.console import Console
from mem0_cli.output import (
format_add_result,
format_memories_table,
format_memories_text,
format_single_memory,
sanitize_agent_data,
)
def _make_console() -> tuple[Console, StringIO]:
buf = StringIO()
return Console(file=buf, force_terminal=False, no_color=True, width=120, highlight=False), buf
SAMPLE_MEMORIES = [
{
"id": "abc-123-def-456",
"memory": "User prefers dark mode",
"score": 0.92,
"created_at": "2026-02-15T10:30:00Z",
"categories": ["preferences"],
},
{
"id": "ghi-789-jkl-012",
"memory": "User uses vim keybindings",
"score": 0.78,
"created_at": "2026-03-01T14:00:00Z",
"categories": ["tools"],
},
]
class TestTextFormat:
def test_format_memories_text(self):
console, buf = _make_console()
format_memories_text(console, SAMPLE_MEMORIES)
output = buf.getvalue()
assert "Found 2 memories" in output
assert "dark mode" in output
assert "vim keybindings" in output
assert "0.92" in output
def test_format_memories_text_empty(self):
console, buf = _make_console()
format_memories_text(console, [])
output = buf.getvalue()
assert "Found 0" in output
def test_format_memories_text_handles_null_fields(self):
console, buf = _make_console()
format_memories_text(console, [{"id": None, "memory": None, "created_at": None}])
assert "Found 1 memories" in buf.getvalue()
class TestTableFormat:
def test_format_memories_table(self):
console, buf = _make_console()
format_memories_table(console, SAMPLE_MEMORIES)
output = buf.getvalue()
assert "dark mode" in output
assert "abc-123-" in output
def test_format_memories_table_empty(self):
console, buf = _make_console()
format_memories_table(console, [])
output = buf.getvalue()
# Should still render (empty table)
assert "ID" in output
def test_format_memories_table_handles_null_fields(self):
console, buf = _make_console()
format_memories_table(console, [{"id": None, "memory": None, "created_at": None}])
output = buf.getvalue()
assert "ID" in output
assert "Memory" in output
class TestSingleMemory:
def test_format_single_memory_text(self):
console, buf = _make_console()
mem = SAMPLE_MEMORIES[0]
format_single_memory(console, mem, "text")
output = buf.getvalue()
assert "dark mode" in output
assert "abc-123-def-456" in output
def test_format_single_memory_json(self):
console, buf = _make_console()
mem = SAMPLE_MEMORIES[0]
format_single_memory(console, mem, "json")
output = buf.getvalue()
assert '"memory"' in output
def test_format_single_memory_handles_null_fields(self):
console, buf = _make_console()
format_single_memory(
console,
{"id": None, "memory": None, "text": "Fallback memory", "created_at": None},
"text",
)
output = buf.getvalue()
assert "Fallback memory" in output
assert "ID:" not in output
class TestAddResult:
def test_format_add_result_text(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123-def-456", "memory": "User prefers dark mode", "event": "ADD"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "dark mode" in output
assert "Added" in output
def test_format_add_result_update_event(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123", "memory": "Updated pref", "event": "UPDATE"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "Updated" in output
def test_format_add_result_noop(self):
console, buf = _make_console()
result = {
"results": [
{"id": "abc-123", "memory": "Same thing", "event": "NOOP"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
assert "No change" in output
def test_format_add_result_quiet(self):
console, buf = _make_console()
result = {"results": [{"id": "abc-123", "memory": "Quiet", "event": "ADD"}]}
format_add_result(console, result, "quiet")
output = buf.getvalue()
assert output.strip() == ""
def test_format_add_result_deduplicates_pending_by_event_id(self):
console, buf = _make_console()
result = {
"results": [
{"status": "PENDING", "event_id": "evt-dup"},
{"status": "PENDING", "event_id": "evt-dup"},
]
}
format_add_result(console, result, "text")
output = buf.getvalue()
# Should show only one PENDING block despite two entries with same event_id
assert output.count("evt-dup") == 2 # event_id line + status hint line
assert output.count("Queued") == 1
def test_format_add_result_empty(self):
console, buf = _make_console()
format_add_result(console, {"results": []}, "text")
output = buf.getvalue()
assert "No memories extracted" in output
class TestSanitizeAgentData:
def test_add_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"event": "ADD",
"metadata": {"x": 1},
"categories": ["a"],
}
]
result = sanitize_agent_data("add", raw)
assert result == [{"id": "abc", "memory": "test", "event": "ADD"}]
def test_add_pending_passthrough(self):
raw = [{"status": "PENDING", "event_id": "evt-123", "metadata": "noise"}]
result = sanitize_agent_data("add", raw)
assert result == [{"status": "PENDING", "event_id": "evt-123"}]
def test_search_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"score": 0.9,
"created_at": "2026-01-01",
"categories": ["a"],
"user_id": "u1",
"agent_id": None,
}
]
result = sanitize_agent_data("search", raw)
assert result == [
{
"id": "abc",
"memory": "test",
"score": 0.9,
"created_at": "2026-01-01",
"categories": ["a"],
}
]
def test_list_projects_fields(self):
raw = [
{
"id": "abc",
"memory": "test",
"created_at": "2026-01-01",
"categories": ["a"],
"user_id": "u1",
}
]
result = sanitize_agent_data("list", raw)
assert result == [
{"id": "abc", "memory": "test", "created_at": "2026-01-01", "categories": ["a"]}
]
def test_get_projects_fields(self):
raw = {
"id": "abc",
"memory": "test",
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
"categories": ["a"],
"metadata": {"k": "v"},
"user_id": "u1",
}
result = sanitize_agent_data("get", raw)
assert "user_id" not in result
assert "id" in result and "memory" in result
def test_update_projects_fields(self):
raw = {"id": "abc", "memory": "updated", "extra": "noise"}
result = sanitize_agent_data("update", raw)
assert result == {"id": "abc", "memory": "updated"}
def test_event_list_projects_fields(self):
raw = [
{
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"graph_status": None,
"latency": 100.0,
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
}
]
result = sanitize_agent_data("event list", raw)
assert result == [
{
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"latency": 100.0,
"created_at": "2026-01-01",
}
]
assert "updated_at" not in result[0]
assert "graph_status" not in result[0]
def test_event_status_flattens_results(self):
raw = {
"id": "evt-1",
"event_type": "ADD",
"status": "SUCCEEDED",
"latency": 100.0,
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
"results": [
{"id": "mem-1", "event": "ADD", "user_id": "alice", "data": {"memory": "dark mode"}}
],
}
result = sanitize_agent_data("event status", raw)
assert result["results"][0] == {
"id": "mem-1",
"event": "ADD",
"user_id": "alice",
"memory": "dark mode",
}
assert "data" not in result["results"][0]
def test_passthrough_commands(self):
for cmd in ("status", "import", "config show", "config get", "config set"):
data = {"key": "value", "other": "stuff"}
assert sanitize_agent_data(cmd, data) == data
def test_none_data(self):
assert sanitize_agent_data("add", None) is None
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the Platform backend (mem0 Platform API client)."""
from __future__ import annotations
from unittest.mock import patch
from mem0_cli.backend.platform import PlatformBackend
from mem0_cli.config import PlatformConfig
def _make_backend() -> PlatformBackend:
# api_key/base_url are only used to build the httpx client; every test here
# patches _request, so no real network calls are made.
return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai"))
class TestDeleteEntities:
def test_multiple_entities_returns_all_results(self):
backend = _make_backend()
responses = {
"/v2/entities/user/alice/": {"message": "user deleted"},
"/v2/entities/agent/bob/": {"message": "agent deleted"},
}
with patch.object(backend, "_request") as mock_request:
mock_request.side_effect = lambda method, path, **kw: responses[path]
result = backend.delete_entities(user_id="alice", agent_id="bob")
# Regression: previously only the last entity's response survived.
assert result == {
"user": {"message": "user deleted"},
"agent": {"message": "agent deleted"},
}
assert mock_request.call_count == 2
def test_single_entity_keyed_by_type(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"message": "user deleted"}):
result = backend.delete_entities(user_id="alice")
assert result == {"user": {"message": "user deleted"}}
def test_no_entities_raises(self):
backend = _make_backend()
import pytest
with pytest.raises(ValueError):
backend.delete_entities()
@@ -0,0 +1,43 @@
from unittest.mock import MagicMock
from mem0_cli.backend.platform import PlatformBackend
def _backend(sample_config):
backend = PlatformBackend(sample_config.platform)
backend._client = MagicMock()
backend._client.request.return_value = MagicMock(
status_code=200,
json=lambda: {"message": "ok"},
headers={},
raise_for_status=lambda: None,
)
return backend
def test_memory_id_path_segments_are_encoded(sample_config):
backend = _backend(sample_config)
backend.get("mem/a?b#c")
backend.update("mem/a?b#c", content="updated")
backend.delete("mem/a?b#c")
paths = [call.args[1] for call in backend._client.request.call_args_list]
assert paths == [
"/v1/memories/mem%2Fa%3Fb%23c/",
"/v1/memories/mem%2Fa%3Fb%23c/",
"/v1/memories/mem%2Fa%3Fb%23c/",
]
def test_entity_and_event_path_segments_are_encoded(sample_config):
backend = _backend(sample_config)
backend.delete_entities(user_id="org/team?active#frag")
backend.get_event("evt/a?b#c")
paths = [call.args[1] for call in backend._client.request.call_args_list]
assert paths == [
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"/v1/event/evt%2Fa%3Fb%23c/",
]
+80
View File
@@ -0,0 +1,80 @@
"""Tests for telemetry subprocess secret handling."""
from __future__ import annotations
import io
import json
import subprocess
import sys
from mem0_cli.config import Mem0Config, save_config
from mem0_cli.telemetry import capture_event
from mem0_cli.telemetry_sender import _load_context
class _CaptureStdin:
def __init__(self):
self.buffer = ""
self.closed = False
def write(self, value: str) -> None:
self.buffer += value
def close(self) -> None:
self.closed = True
class _DummyProcess:
def __init__(self):
self.stdin = _CaptureStdin()
def test_capture_event_writes_context_to_stdin_not_argv(isolate_config, monkeypatch):
config = Mem0Config()
config.platform.api_key = "m0-test-secret"
config.telemetry.anonymous_id = "cli-anon-test"
save_config(config)
captured: dict[str, object] = {}
proc = _DummyProcess()
def fake_popen(args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return proc
monkeypatch.setattr("mem0_cli.telemetry.subprocess.Popen", fake_popen)
capture_event("unit_test_event", {"case": "stdin-secret"})
argv = captured["args"]
assert argv == [sys.executable, "-m", "mem0_cli.telemetry_sender"]
assert all("m0-test-secret" not in arg for arg in argv)
kwargs = captured["kwargs"]
assert kwargs["stdin"] == subprocess.PIPE
assert kwargs["text"] is True
ctx = json.loads(proc.stdin.buffer)
assert ctx["mem0_api_key"] == "m0-test-secret"
assert ctx["payload"]["event"] == "unit_test_event"
assert proc.stdin.closed
def test_load_context_reads_from_stdin(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender"])
monkeypatch.setattr("sys.stdin", io.StringIO('{"payload": {"event": "stdin"}}'))
ctx = _load_context()
assert ctx["payload"]["event"] == "stdin"
def test_load_context_falls_back_to_argv(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender", '{"payload": {"event": "argv"}}'])
monkeypatch.setattr("sys.stdin", io.StringIO(""))
ctx = _load_context()
assert ctx["payload"]["event"] == "argv"