Files
microfish/backend/tests/test_graph_backend_routes.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

202 lines
7.0 KiB
Python

from types import SimpleNamespace
import pytest
from flask import Flask, g
import app.api.graph as graph_api
from app.api import graph_bp
from app.config import Config
from app.models.project import ProjectManager
from app.security.policy import Role
from app.services.local_graph_builder import LocalGraphBuilderService
class _LocalGraphSpy:
instances = []
def __init__(self, session_factory, **kwargs):
self.session_factory = session_factory
self.kwargs = kwargs
self.calls = []
self.__class__.instances.append(self)
def get_graph_data(self, graph_id):
self.calls.append(("get_graph_data", graph_id))
return {"graph_id": graph_id, "node_count": 0, "edge_count": 0, "nodes": [], "edges": []}
def delete_graph(self, graph_id):
self.calls.append(("delete_graph", graph_id))
def make_app():
app = Flask(__name__)
app.config.update(TESTING=True, SECRET_KEY="unit-test")
app.extensions["crowdsight_session_factory"] = lambda: None
app.register_blueprint(graph_bp, url_prefix="/api/graph")
return app
def set_actor():
g.auth_context = SimpleNamespace(
user=SimpleNamespace(id="user-a"),
membership=SimpleNamespace(role=Role.USER),
organization=SimpleNamespace(id="org-a"),
)
def test_delete_project_rejects_out_of_scope_project_before_delete(monkeypatch):
app = make_app()
delete_calls = []
monkeypatch.setattr(graph_api, "_scoped_project", lambda project_id: None)
monkeypatch.setattr(
ProjectManager,
"delete_project",
lambda project_id, **kwargs: delete_calls.append((project_id, kwargs)) or True,
)
with app.test_request_context("/api/graph/project/project-b", method="DELETE"):
set_actor()
result = graph_api.delete_project.__wrapped__("project-b")
if isinstance(result, tuple):
response, status = result
else:
response, status = result, 200
assert status == 404
assert response.get_json()["success"] is False
assert delete_calls == []
def test_local_graph_data_route_uses_local_adapter_without_constructing_zep(monkeypatch):
app = make_app()
old_backend = Config.MEMORY_BACKEND
old_key = Config.ZEP_API_KEY
try:
monkeypatch.setattr(Config, "MEMORY_BACKEND", "local")
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
monkeypatch.setattr(
graph_api,
"_scoped_graph",
lambda graph_id: SimpleNamespace(project_id="project-local"),
)
monkeypatch.setattr(graph_api, "LocalGraphBuilderService", _LocalGraphSpy)
monkeypatch.setattr(
graph_api,
"GraphBuilderService",
lambda **kwargs: pytest.fail("local graph read must not construct Zep"),
)
_LocalGraphSpy.instances.clear()
with app.test_request_context("/api/graph/data/graph-local"):
set_actor()
response = graph_api.get_graph_data.__wrapped__("graph-local")
if isinstance(response, tuple):
assert response[1] == 200
payload = response[0].get_json()
else:
payload = response.get_json()
assert payload["success"] is True
assert payload["data"]["graph_id"] == "graph-local"
assert _LocalGraphSpy.instances[0].kwargs["organization_id"] == "org-a"
assert _LocalGraphSpy.instances[0].kwargs["project_id"] == "project-local"
assert _LocalGraphSpy.instances[0].calls == [("get_graph_data", "graph-local")]
finally:
Config.MEMORY_BACKEND = old_backend
Config.ZEP_API_KEY = old_key
def test_local_graph_delete_route_uses_local_adapter_without_constructing_zep(monkeypatch):
app = make_app()
old_backend = Config.MEMORY_BACKEND
old_key = Config.ZEP_API_KEY
try:
monkeypatch.setattr(Config, "MEMORY_BACKEND", "local")
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
monkeypatch.setattr(
graph_api,
"_scoped_graph",
lambda graph_id: SimpleNamespace(project_id="project-local"),
)
monkeypatch.setattr(graph_api, "LocalGraphBuilderService", _LocalGraphSpy)
monkeypatch.setattr(
graph_api,
"GraphBuilderService",
lambda **kwargs: pytest.fail("local graph delete must not construct Zep"),
)
_LocalGraphSpy.instances.clear()
with app.test_request_context("/api/graph/delete/graph-local", method="DELETE"):
set_actor()
response = graph_api.delete_graph.__wrapped__("graph-local")
payload = response[0].get_json() if isinstance(response, tuple) else response.get_json()
assert payload["success"] is True
assert _LocalGraphSpy.instances[0].calls == [("delete_graph", "graph-local")]
finally:
Config.MEMORY_BACKEND = old_backend
Config.ZEP_API_KEY = old_key
def test_local_graph_builder_deletes_only_the_scoped_graph():
from sqlalchemy import create_engine
from app.db import Base, create_session_factory
from app.models.memory import MemoryEdge, MemoryGraph, MemoryNode
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session_factory = create_session_factory(engine)
try:
builder = LocalGraphBuilderService(
session_factory,
organization_id="org-a",
project_id="project-a",
extraction_client=object(),
)
graph_id = builder.create_graph(name="Local")
with session_factory() as session:
first = MemoryNode(
id="node-a",
graph_id=graph_id,
canonical_name="A",
normalized_name="a",
)
second = MemoryNode(
id="node-b",
graph_id=graph_id,
canonical_name="B",
normalized_name="b",
)
session.add_all([first, second])
session.flush()
session.add(
MemoryEdge(
id="edge-ab",
graph_id=graph_id,
source_node_id=first.id,
target_node_id=second.id,
relation="RELATED",
fact="A is related to B.",
)
)
session.commit()
other_builder = LocalGraphBuilderService(
session_factory,
organization_id="org-b",
project_id="project-b",
extraction_client=object(),
)
with pytest.raises(ValueError, match="memory_graph_not_found"):
other_builder.delete_graph(graph_id)
builder.delete_graph(graph_id)
with session_factory() as session:
assert session.get(MemoryGraph, graph_id) is None
assert session.get(MemoryNode, "node-a") is None
assert session.get(MemoryNode, "node-b") is None
assert session.get(MemoryEdge, "edge-ab") is None
finally:
engine.dispose()