555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
412 lines
17 KiB
Python
412 lines
17 KiB
Python
"""Tests for PostHog identity stitching: anon → email alias on MemoryClient init.
|
|
|
|
Covers the four matrix cases (OSS-only, CLI-only, both, already-aliased) plus
|
|
failure modes: missing config, malformed JSON, read-only filesystem,
|
|
broken posthog client. Telemetry must never raise.
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_mem0_dir(tmp_path, monkeypatch):
|
|
"""Point the mem0 setup module at a tempdir for the duration of the test."""
|
|
monkeypatch.setenv("MEM0_DIR", str(tmp_path))
|
|
# Reload setup so module-level mem0_dir picks up the env var.
|
|
import mem0.memory.setup as setup_module
|
|
|
|
importlib.reload(setup_module)
|
|
yield tmp_path
|
|
# Restore default state.
|
|
monkeypatch.delenv("MEM0_DIR", raising=False)
|
|
importlib.reload(setup_module)
|
|
|
|
|
|
def _write_config(tmp_path: Path, payload: dict) -> Path:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(json.dumps(payload))
|
|
return config_path
|
|
|
|
|
|
# ─── setup_config idempotency ────────────────────────────────────────────────
|
|
|
|
|
|
class TestSetupConfigIdempotent:
|
|
def test_creates_config_when_missing(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
setup_module.setup_config()
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert "user_id" in config and config["user_id"]
|
|
|
|
def test_backfills_user_id_when_only_telemetry_present(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(
|
|
tmp_mem0_dir,
|
|
{"telemetry": {"anonymous_id": "cli-anon-abc123"}},
|
|
)
|
|
setup_module.setup_config()
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert config.get("user_id"), "user_id must be backfilled for CLI-first users"
|
|
assert config["telemetry"]["anonymous_id"] == "cli-anon-abc123"
|
|
|
|
def test_does_not_overwrite_existing_user_id(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(tmp_mem0_dir, {"user_id": "existing-uuid"})
|
|
setup_module.setup_config()
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert config["user_id"] == "existing-uuid"
|
|
|
|
def test_handles_malformed_json(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
(tmp_mem0_dir / "config.json").write_text("{not json")
|
|
setup_module.setup_config() # must not raise
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert "user_id" in config
|
|
|
|
|
|
# ─── read_anon_ids ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestReadAnonIds:
|
|
def test_returns_oss_only(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
|
anon = setup_module.read_anon_ids()
|
|
assert anon == {"oss": "oss-uuid", "cli": None, "aliased_pairs": []}
|
|
|
|
def test_returns_cli_only(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(
|
|
tmp_mem0_dir,
|
|
{"telemetry": {"anonymous_id": "cli-anon-123"}},
|
|
)
|
|
anon = setup_module.read_anon_ids()
|
|
assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_pairs": []}
|
|
|
|
def test_returns_both(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(
|
|
tmp_mem0_dir,
|
|
{
|
|
"user_id": "oss-uuid",
|
|
"telemetry": {"anonymous_id": "cli-anon-123", "aliased_pairs": ["pair-marker"]},
|
|
},
|
|
)
|
|
anon = setup_module.read_anon_ids()
|
|
assert anon == {
|
|
"oss": "oss-uuid",
|
|
"cli": "cli-anon-123",
|
|
"aliased_pairs": ["pair-marker"],
|
|
}
|
|
|
|
def test_no_config_returns_all_none(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
anon = setup_module.read_anon_ids()
|
|
assert anon == {"oss": None, "cli": None, "aliased_pairs": []}
|
|
|
|
def test_malformed_json_does_not_raise(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
(tmp_mem0_dir / "config.json").write_text("{not json")
|
|
anon = setup_module.read_anon_ids()
|
|
assert anon == {"oss": None, "cli": None, "aliased_pairs": []}
|
|
|
|
|
|
# ─── mark_aliased ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMarkAliased:
|
|
def test_writes_aliased_pair_preserving_other_fields(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(
|
|
tmp_mem0_dir,
|
|
{
|
|
"user_id": "oss-uuid",
|
|
"telemetry": {"anonymous_id": "cli-anon-123"},
|
|
},
|
|
)
|
|
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert config["user_id"] == "oss-uuid"
|
|
assert config["telemetry"]["anonymous_id"] == "cli-anon-123"
|
|
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
|
assert setup_module.is_aliased("oss-uuid", "user@example.com")
|
|
|
|
def test_creates_telemetry_section_when_missing(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
|
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
|
|
|
def test_tracks_each_pair_independently(self, tmp_mem0_dir):
|
|
import mem0.memory.setup as setup_module
|
|
|
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
|
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
|
assert setup_module.is_aliased("oss-uuid", "user@example.com")
|
|
assert not setup_module.is_aliased("new-uuid", "user@example.com")
|
|
assert not setup_module.is_aliased("oss-uuid", "other@example.com")
|
|
|
|
|
|
# ─── capture_identify ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCaptureIdentify:
|
|
def test_fires_identify_with_anon_distinct_id(self):
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_identify("anon-123", "user@example.com")
|
|
mock_ph = mock_posthog_cls.return_value
|
|
mock_ph.capture.assert_called_once()
|
|
_, kwargs = mock_ph.capture.call_args
|
|
assert kwargs["distinct_id"] == "user@example.com"
|
|
assert kwargs["event"] == "$identify"
|
|
assert kwargs["properties"]["$anon_distinct_id"] == "anon-123"
|
|
|
|
def test_skips_when_anon_equals_email(self):
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_identify("user@example.com", "user@example.com")
|
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
|
|
|
def test_skips_when_inputs_empty(self):
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_identify("", "user@example.com")
|
|
at.capture_identify("anon-123", "")
|
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
|
|
|
def test_noop_when_telemetry_disabled(self):
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_identify("anon-123", "user@example.com") # must not raise
|
|
assert at.posthog is None
|
|
|
|
def test_does_not_raise_on_posthog_error(self):
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("boom")
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_identify("anon-123", "user@example.com") # must not raise
|
|
|
|
def test_identify_is_in_lifecycle_events(self):
|
|
"""$identify must bypass the 90% sampling drop."""
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
assert "$identify" in telemetry_module._LIFECYCLE_EVENTS
|
|
|
|
|
|
# ─── _maybe_alias_anon_to_email integration ──────────────────────────────────
|
|
|
|
|
|
class TestMaybeAliasAnonToEmail:
|
|
"""Test the alias helper in isolation by mocking out the config readers
|
|
and the telemetry client, since module-level setup_config() side effects
|
|
make end-to-end fixturing awkward."""
|
|
|
|
def test_fires_identify_for_oss_uuid(self):
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=False),
|
|
patch.object(client_main, "mark_aliased") as mark,
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
telemetry.capture_identify.return_value = True
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
telemetry.capture_identify.assert_called_once_with("oss-uuid", "user@example.com")
|
|
mark.assert_called_once_with("oss-uuid", "user@example.com")
|
|
|
|
def test_fires_identify_for_cli_anon(self):
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=False),
|
|
patch.object(client_main, "mark_aliased"),
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
telemetry.capture_identify.return_value = True
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com")
|
|
|
|
def test_fires_identify_for_both_anon_ids(self):
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=False),
|
|
patch.object(client_main, "mark_aliased"),
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
telemetry.capture_identify.return_value = True
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
assert telemetry.capture_identify.call_count == 2
|
|
calls = {c.args for c in telemetry.capture_identify.call_args_list}
|
|
assert ("oss-uuid", "user@example.com") in calls
|
|
assert ("cli-anon", "user@example.com") in calls
|
|
|
|
def test_skips_when_pair_already_aliased(self):
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=True),
|
|
patch.object(client_main, "mark_aliased") as mark,
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
telemetry.capture_identify.assert_not_called()
|
|
mark.assert_not_called()
|
|
|
|
def test_skips_when_email_invalid(self):
|
|
from mem0.client import main as client_main
|
|
|
|
with patch.object(client_main, "client_telemetry") as telemetry:
|
|
client_main._maybe_alias_anon_to_email(None)
|
|
client_main._maybe_alias_anon_to_email("")
|
|
client_main._maybe_alias_anon_to_email("not-an-email")
|
|
telemetry.capture_identify.assert_not_called()
|
|
|
|
def test_skips_when_telemetry_disabled(self):
|
|
"""When client_telemetry.posthog is None (MEM0_TELEMETRY=false), do nothing —
|
|
no fs read, no fs write, no event. Re-enabling telemetry later must still alias."""
|
|
from mem0.client import main as client_main
|
|
|
|
disabled = MagicMock()
|
|
disabled.posthog = None
|
|
with (
|
|
patch.object(client_main, "client_telemetry", disabled),
|
|
patch.object(client_main, "read_anon_ids") as read,
|
|
patch.object(client_main, "mark_aliased") as mark,
|
|
):
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
read.assert_not_called()
|
|
mark.assert_not_called()
|
|
disabled.capture_identify.assert_not_called()
|
|
|
|
def test_does_not_raise_on_telemetry_failure(self):
|
|
from mem0.client import main as client_main
|
|
|
|
mock_telemetry = MagicMock()
|
|
mock_telemetry.capture_identify.side_effect = RuntimeError("boom")
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=False),
|
|
patch.object(client_main, "mark_aliased") as mark,
|
|
patch.object(client_main, "client_telemetry", mock_telemetry),
|
|
):
|
|
client_main._maybe_alias_anon_to_email("user@example.com") # must not raise
|
|
mark.assert_not_called()
|
|
|
|
def test_skips_anon_id_equal_to_email(self):
|
|
"""Defensive: if the anon_id somehow already is the email, don't self-alias."""
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(
|
|
client_main,
|
|
"read_anon_ids",
|
|
return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []},
|
|
),
|
|
patch.object(client_main, "is_aliased", return_value=False),
|
|
patch.object(client_main, "mark_aliased"),
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
telemetry.capture_identify.assert_not_called()
|
|
|
|
def test_does_not_raise_on_read_failure(self):
|
|
"""If read_anon_ids itself raises (e.g. IO error), helper must swallow it."""
|
|
from mem0.client import main as client_main
|
|
|
|
with (
|
|
patch.object(client_main, "read_anon_ids", side_effect=OSError("fs broken")),
|
|
patch.object(client_main, "client_telemetry") as telemetry,
|
|
):
|
|
client_main._maybe_alias_anon_to_email("user@example.com") # must not raise
|
|
telemetry.capture_identify.assert_not_called()
|
|
|
|
|
|
# ─── End-to-end idempotency through real config ──────────────────────────────
|
|
|
|
|
|
class TestEndToEndIdempotency:
|
|
"""Verify the real config flow: two consecutive _maybe_alias_anon_to_email
|
|
calls fire $identify exactly once thanks to the persisted pair marker."""
|
|
|
|
def test_second_call_is_noop_after_pair_marker_persisted(self, tmp_mem0_dir):
|
|
# Pre-populate config with an OSS user_id only.
|
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
|
# Reload setup so it uses the tempdir, then reload client.main so it
|
|
# picks up the freshly-loaded read_anon_ids/mark_aliased bindings.
|
|
import mem0.memory.setup as setup_module
|
|
|
|
importlib.reload(setup_module)
|
|
from mem0.client import main as client_main
|
|
|
|
importlib.reload(client_main)
|
|
|
|
with patch.object(client_main, "client_telemetry") as telemetry:
|
|
telemetry.capture_identify.return_value = True
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
first_call_count = telemetry.capture_identify.call_count
|
|
assert first_call_count >= 1
|
|
|
|
# Second call should hit the aliased_pairs short-circuit.
|
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
|
assert telemetry.capture_identify.call_count == first_call_count
|
|
|
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
|
assert len(config["telemetry"]["aliased_pairs"]) == 1
|