Files
microfish/backend/tests/test_memory_repository.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

77 lines
3.2 KiB
Python

from sqlalchemy import create_engine
from app.db import Base, create_session_factory
from app.models.memory import MemoryEdge, MemoryGraph, MemoryNode
from app.services.memory_repository import SqlAlchemyMemoryRepository
def make_memory_session():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
return engine, create_session_factory(engine)
def test_memory_repository_persists_graph_and_searches_structured_nodes():
engine, session_factory = make_memory_session()
try:
with session_factory() as session:
repo = SqlAlchemyMemoryRepository(session, organization_id="org-a", graph_id="graph-a")
graph = repo.create_graph(project_id="project-a", ontology={"entity_types": ["Person"]})
assert graph.id == "graph-a"
alice = repo.upsert_node(
canonical_name="Alice",
labels=["Person"],
aliases=["A. Example"],
attributes={"role": "founder"},
summary="A founder mentioned in the source.",
confidence=0.91,
)
bob = repo.upsert_node(canonical_name="Bob", labels=["Person"])
edge = repo.upsert_edge(
source_node_id=alice.id,
target_node_id=bob.id,
relation="KNOWS",
fact="Alice knows Bob.",
confidence=0.8,
)
episode = repo.add_episode(
source_type="document",
source_ref="upload-1",
normalized_text="Alice knows Bob.",
summary="A relationship statement.",
)
session.commit()
assert edge.graph_id == graph.id
assert episode.graph_id == graph.id
results = repo.search("alice")
assert [node.canonical_name for node in results.nodes] == ["Alice"]
assert results.total_count == 1
assert repo.get_node(alice.id).attributes == {"role": "founder"}
finally:
engine.dispose()
def test_memory_repository_fails_closed_for_wrong_organization_or_graph():
engine, session_factory = make_memory_session()
try:
with session_factory() as session:
owner_repo = SqlAlchemyMemoryRepository(session, organization_id="org-a", graph_id="graph-a")
owner_repo.create_graph(project_id="project-a")
owner_node = owner_repo.upsert_node(canonical_name="Private Alice", labels=["Person"])
other_repo = SqlAlchemyMemoryRepository(session, organization_id="org-b", graph_id="graph-b")
other_repo.create_graph(project_id="project-b")
other_node = other_repo.upsert_node(canonical_name="Private Bob", labels=["Person"])
session.commit()
wrong_org = SqlAlchemyMemoryRepository(session, organization_id="org-b", graph_id="graph-b")
assert wrong_org.get_node(owner_node.id) is None
assert wrong_org.search("alice").nodes == []
wrong_graph = SqlAlchemyMemoryRepository(session, organization_id="org-a", graph_id="graph-other")
assert wrong_graph.get_node(owner_node.id) is None
assert wrong_graph.get_node(other_node.id) is None
finally:
engine.dispose()