Files
mem0/integrations/mem0-agent/tests/test_contract.py
T
Deshraj Yadav f4949c55af refactor(mem0-agent): stop requiring org/project in client config
An API key is already bound to one (org_id, project_id) server-side, so storing a
copy locally was redundant state that can go stale. Verified live that add,
get_all and feedback all succeed with no ids in the body at all.

- both ids are now purely an OVERRIDE, for pointing one key at a different
  project in the same org; half an override is ignored rather than half-applied
- identity resolution no longer persists scope as a side effect
- the project-config endpoints (the only ones needing ids in the URL) resolve
  them from /v1/ping/ and cache the result
- corrects an over-generalized finding: feedback does not require the ids; it
  404'd earlier only because that memory lived outside the key's project
- onboarding now recommends a key issued for a dedicated project over an override

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:13:52 -07:00

274 lines
9.5 KiB
Python

"""The contract tests. Each one pins a rule that was learned by breaking it against
the live API -- if one of these fails, the client has regressed to v1 behavior."""
import json
import pytest
from mem0_agent.api import Api, ContractError, expiry_date, results_of
from mem0_agent.breaker import Breaker
from mem0_agent.config import filters as F
from mem0_agent.config.project_config import DURABLE_TYPES, TYPES
class FakeResponse:
def __init__(self, status=200, body=None):
self.status = status
self._body = body if body is not None else {"results": []}
def read(self):
return json.dumps(self._body).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
class Recorder:
"""Stands in for urlopen so we can inspect exactly what would go on the wire."""
def __init__(self, status=200, body=None):
self.calls = []
self.status = status
self.body = body
def __call__(self, req, timeout=None):
self.calls.append({
"method": req.get_method(),
"url": req.full_url,
"body": json.loads(req.data.decode()) if req.data else None,
})
return FakeResponse(self.status, self.body)
@property
def last(self):
return self.calls[-1]
def make_api(**kw):
rec = Recorder(**kw.pop("recorder_kw", {}))
api = Api("key", org_id="org_X", project_id="proj_Y", opener=rec,
breaker=Breaker(None), **kw)
return api, rec
# --- Rule 1: a scope OVERRIDE travels in the body, never as a query param.
# Omitting it is normal: the API key is already bound to one (org, project). ---
def test_writes_send_the_override_in_the_body():
api, rec = make_api()
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
assert "project_id=" not in rec.last["url"]
def test_reads_send_the_override_in_the_body():
api, rec = make_api()
api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y"
assert "project_id=" not in rec.last["url"]
def test_feedback_carries_the_override_when_one_is_configured():
"""Feedback 404s only when the memory lives in a project the key is not bound to and
no override is sent -- not, as first assumed, on every unpinned call."""
api, rec = make_api()
api.feedback("mem-1", "POSITIVE", "referenced in session")
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
def test_unpinned_calls_send_no_scope_and_let_the_key_decide():
"""An API key is already bound to one (org, project) server-side, so the ids are an
override, not a requirement. Verified live: add, get_all and feedback all return 200
with nothing in the body."""
api, rec = make_api()
api.org_id = api.project_id = None
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
body = rec.last["body"]
assert "project_id" not in body and "org_id" not in body
api.get_all(F.all_in_scope("u", "app"))
assert "project_id" not in rec.last["body"]
api.feedback("mem-1", "POSITIVE")
assert "project_id" not in rec.last["body"]
def test_override_travels_in_the_body_when_both_ids_are_set():
api, rec = make_api()
api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
assert "project_id=" not in rec.last["url"], "query params are silently ignored"
def test_half_an_override_is_no_override():
"""One id alone would be ignored by the backend; sending it would only mislead."""
api, _ = make_api()
api.org_id = None
assert api._pin() == {}
def test_project_endpoints_resolve_scope_from_the_key():
"""Only the project-config endpoints need the ids, and they ask the API for them."""
api, rec = make_api(recorder_kw={"body": {"org_id": "org_ping", "project_id": "proj_ping"}})
api.org_id = api.project_id = None
api.project_get(fields=["decay"])
assert "/organizations/org_ping/projects/proj_ping/" in rec.last["url"]
assert rec.calls[0]["url"].endswith("/v1/ping/"), "scope comes from ping, not from config"
# --- Rule 2: latest_only on every read, or superseded facts resurface ---
@pytest.mark.parametrize("method,args", [
("get_all", ({"AND": []},)),
("search", ("q", {"AND": []})),
])
def test_reads_default_to_latest_only(method, args):
api, rec = make_api()
getattr(api, method)(*args)
assert rec.last["body"]["latest_only"] is True
def test_strict_mode_blocks_superseded_reads():
api = Api("key", org_id="o", project_id="p", strict=True, opener=Recorder())
with pytest.raises(ContractError):
api.get_all({"AND": []}, latest_only=False)
def test_superseded_reads_allowed_when_explicitly_auditing():
api = Api("key", org_id="o", project_id="p", strict=True, opener=Recorder(),
breaker=Breaker(None))
status, _ = api.get_all({"AND": []}, latest_only=False, _allow_superseded=True)
assert status == 200
# --- Rule 3: metadata.type is the read-time taxonomy, categories are secondary ---
def test_type_filters_match_metadata_and_categories():
f = F.context_pack("u", "app")
blob = json.dumps(f)
for t in DURABLE_TYPES:
assert f'{{"metadata": {{"type": "{t}"}}}}' in blob.replace("'", '"')
assert '"categories"' in blob
# --- Rule 4: NOT takes a list ---
def test_not_clauses_are_lists():
for f in (F.user_prefs("u"), F.context_pack("u", "app")):
for clause in json.dumps(f).split('"NOT": ')[1:]:
assert clause.lstrip().startswith("["), "NOT must wrap a list; the object form 400s"
def test_user_scope_excludes_project_records():
"""Implicit null scoping does not work -- the NOT clause is what makes this correct."""
f = F.user_prefs("u")
assert {"NOT": [{"app_id": "*"}]} in f["AND"]
def test_context_pack_spans_both_scopes():
f = F.context_pack("u", "app")
scope = [c for c in f["AND"] if "OR" in c][0]["OR"]
assert {"app_id": "app"} in scope
assert {"NOT": [{"app_id": "*"}]} in scope
# --- entity rules ---
def test_no_run_id_or_agent_id_anywhere():
"""v1 wrote summaries with run_id that no read path could ever return."""
blob = json.dumps([
F.context_pack("u", "a"), F.user_prefs("u"), F.session_state("u", "a", "s"),
F.error_assist("u", "a"), F.all_in_scope("u", "a"), F.by_session("u", "a", "s"),
])
assert "run_id" not in blob and "agent_id" not in blob
def test_session_state_is_found_by_metadata():
f = F.session_state("u", "app", "sess-1")
assert {"metadata": {"type": "session_state"}} in f["AND"]
assert {"metadata": {"session_id": "sess-1"}} in f["AND"]
# --- endpoint quirks ---
def test_delete_all_uses_query_params_not_a_body():
api, rec = make_api()
api.delete_all(user_id="u")
assert rec.last["body"] is None
assert "user_id=u" in rec.last["url"]
def test_project_fields_are_repeated_params():
api, rec = make_api()
api.project_get(fields=["custom_instructions", "decay"])
assert "fields=custom_instructions&fields=decay" in rec.last["url"]
assert "fields=custom_instructions%2C" not in rec.last["url"]
def test_get_all_paginates_via_query_params():
api, rec = make_api()
api.get_all({"AND": []}, page=2, page_size=30)
assert "page=2" in rec.last["url"] and "page_size=30" in rec.last["url"]
assert "filters" in rec.last["body"]
# --- resilience: hooks must never block a session ---
def test_network_errors_fail_open():
def boom(req, timeout=None):
raise OSError("connection reset")
api = Api("key", org_id="o", project_id="p", opener=boom, breaker=Breaker(None))
status, body = api.get_all({"AND": []})
assert status == 0 and "error" in body
def test_breaker_opens_after_threshold_and_reports_once():
clock = [1000.0]
b = Breaker(None, threshold=3, cooldown=600, clock=lambda: clock[0])
for _ in range(3):
b.record_failure()
assert b.is_open
assert b.take_notice() is not None
assert b.take_notice() is None, "the outage should be announced once, not every call"
clock[0] += 601
assert b.allow()
def test_client_side_errors_do_not_trip_the_breaker():
"""A 400 is our bug, not an outage -- tripping on it would disable memory needlessly."""
import urllib.error
def bad_request(req, timeout=None):
raise urllib.error.HTTPError(req.full_url, 400, "Bad Request", {}, None)
b = Breaker(None)
api = Api("key", org_id="o", project_id="p", opener=bad_request, breaker=b)
api.get_all({"AND": []})
assert b.allow()
def test_breaker_short_circuits_when_open():
b = Breaker(None, threshold=1)
b.record_failure()
rec = Recorder()
api = Api("key", org_id="o", project_id="p", opener=rec, breaker=b)
status, _ = api.get_all({"AND": []})
assert status == 0 and rec.calls == [], "no request should leave the machine while open"
# --- helpers ---
def test_results_of_handles_both_shapes():
assert results_of({"results": [{"id": 1}]}) == [{"id": 1}]
assert results_of([{"id": 2}]) == [{"id": 2}]
assert results_of(None) == []
def test_expiry_date_format():
assert expiry_date(14, now=0) == "1970-01-15"
def test_taxonomy_is_closed():
assert set(DURABLE_TYPES) < set(TYPES)
assert "session_state" in TYPES and "session_state" not in DURABLE_TYPES
assert "auto_capture" not in TYPES, "v1's catch-all bucket must not come back"