Skip to content

Commit 6f207c2

Browse files
authored
test(api): add recent activity hydration regression coverage (#716)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent cff31c5 commit 6f207c2

File tree

1 file changed

+187
-0
lines changed

1 file changed

+187
-0
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Tests for graph context hydration in to_graph_context().
2+
3+
Proves that recent-activity/build-context hydration batches entity lookups
4+
for entities, observations, and relations in a single repository call.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from datetime import datetime, timezone
10+
from types import SimpleNamespace
11+
12+
import pytest
13+
14+
from basic_memory.api.v2.utils import to_graph_context
15+
from basic_memory.schemas.search import SearchItemType
16+
from basic_memory.services.context_service import (
17+
ContextMetadata,
18+
ContextResult as ServiceContextResult,
19+
ContextResultItem,
20+
ContextResultRow,
21+
)
22+
23+
24+
# --- Helpers ---
25+
26+
27+
def _make_entity(id: int, title: str, external_id: str) -> SimpleNamespace:
28+
return SimpleNamespace(id=id, title=title, external_id=external_id)
29+
30+
31+
def _make_row(*, type: str, id: int, root_id: int, **kwargs) -> ContextResultRow:
32+
now = kwargs.pop("created_at", datetime.now(timezone.utc))
33+
defaults = dict(
34+
title=f"Item {id}",
35+
permalink=f"notes/{id}",
36+
file_path=f"notes/{id}.md",
37+
depth=0,
38+
root_id=root_id,
39+
created_at=now,
40+
)
41+
defaults.update(kwargs)
42+
return ContextResultRow(type=type, id=id, **defaults)
43+
44+
45+
class SpyEntityRepository:
46+
"""Tracks batched ID lookups and returns entities from a preset map."""
47+
48+
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
49+
self.entities_by_id = entities_by_id
50+
self.calls: list[list[int]] = []
51+
52+
async def find_by_ids(self, ids: list[int]):
53+
self.calls.append(ids)
54+
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
55+
56+
57+
# --- Single batch fetch (N+1 elimination) ---
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_to_graph_context_batches_entity_hydration_for_recent_activity():
62+
"""Mixed entity, observation, and relation items must hydrate in one lookup."""
63+
repo = SpyEntityRepository(
64+
{
65+
1: _make_entity(1, "Root", "ext-root"),
66+
2: _make_entity(2, "Child", "ext-child"),
67+
3: _make_entity(3, "Peer", "ext-peer"),
68+
}
69+
)
70+
now = datetime.now(timezone.utc)
71+
72+
root_entity = _make_row(
73+
type="entity",
74+
id=1,
75+
root_id=1,
76+
title="Root",
77+
permalink="notes/root",
78+
file_path="notes/root.md",
79+
created_at=now,
80+
)
81+
root_observation = _make_row(
82+
type="observation",
83+
id=10,
84+
root_id=1,
85+
title="fact: observed",
86+
permalink="notes/root/observations/fact/observed",
87+
file_path="notes/root.md",
88+
category="fact",
89+
content="observed",
90+
entity_id=1,
91+
created_at=now,
92+
)
93+
root_relation = _make_row(
94+
type="relation",
95+
id=20,
96+
root_id=1,
97+
title="links_to: Child",
98+
permalink="notes/root",
99+
file_path="notes/root.md",
100+
relation_type="links_to",
101+
from_id=1,
102+
to_id=2,
103+
depth=1,
104+
created_at=now,
105+
)
106+
child_observation = _make_row(
107+
type="observation",
108+
id=11,
109+
root_id=11,
110+
title="note: child update",
111+
permalink="notes/child/observations/note/update",
112+
file_path="notes/child.md",
113+
category="note",
114+
content="child update",
115+
entity_id=2,
116+
created_at=now,
117+
)
118+
peer_entity = _make_row(
119+
type="entity",
120+
id=3,
121+
root_id=11,
122+
title="Peer",
123+
permalink="notes/peer",
124+
file_path="notes/peer.md",
125+
depth=1,
126+
created_at=now,
127+
)
128+
129+
context = ServiceContextResult(
130+
results=[
131+
ContextResultItem(
132+
primary_result=root_entity,
133+
observations=[root_observation],
134+
related_results=[root_relation],
135+
),
136+
ContextResultItem(
137+
primary_result=child_observation,
138+
observations=[],
139+
related_results=[peer_entity],
140+
),
141+
],
142+
metadata=ContextMetadata(
143+
types=[
144+
SearchItemType.ENTITY,
145+
SearchItemType.OBSERVATION,
146+
SearchItemType.RELATION,
147+
],
148+
depth=1,
149+
primary_count=2,
150+
related_count=2,
151+
total_relations=1,
152+
total_observations=1,
153+
),
154+
)
155+
156+
graph = await to_graph_context(context, entity_repository=repo, page=1, page_size=10)
157+
158+
assert len(repo.calls) == 1, f"Expected 1 entity lookup, got {len(repo.calls)}"
159+
assert set(repo.calls[0]) == {1, 2, 3}
160+
161+
first_result = graph.results[0]
162+
assert first_result.primary_result.external_id == "ext-root"
163+
assert first_result.observations[0].entity_external_id == "ext-root"
164+
assert first_result.observations[0].title == "Root"
165+
166+
relation = first_result.related_results[0]
167+
assert relation.from_entity == "Root"
168+
assert relation.from_entity_external_id == "ext-root"
169+
assert relation.to_entity == "Child"
170+
assert relation.to_entity_external_id == "ext-child"
171+
172+
second_result = graph.results[1]
173+
assert second_result.primary_result.entity_external_id == "ext-child"
174+
assert second_result.primary_result.title == "Child"
175+
assert second_result.related_results[0].external_id == "ext-peer"
176+
177+
178+
@pytest.mark.asyncio
179+
async def test_to_graph_context_empty_results_skip_entity_lookup():
180+
"""An empty context result should not perform any entity hydration lookup."""
181+
repo = SpyEntityRepository({})
182+
context = ServiceContextResult(results=[], metadata=ContextMetadata(depth=1))
183+
184+
graph = await to_graph_context(context, entity_repository=repo)
185+
186+
assert repo.calls == []
187+
assert list(graph.results) == []

0 commit comments

Comments
 (0)