Initial commit: pi_mcps monorepo with BigMind MCP server
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
"""Tests for memory_store CRUD operations."""
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from bigmind import memory_store
|
||||
from bigmind.db import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
return memory_store.get_or_create_user("testuser", "Test User")
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_create_user(self, temp_db):
|
||||
u = memory_store.get_or_create_user("alice")
|
||||
assert u["username"] == "alice"
|
||||
assert u["id"]
|
||||
|
||||
def test_get_existing_user(self, temp_db):
|
||||
u1 = memory_store.get_or_create_user("bob")
|
||||
u2 = memory_store.get_or_create_user("bob")
|
||||
assert u1["id"] == u2["id"]
|
||||
|
||||
def test_get_current_username_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BIGMIND_USER", "envuser")
|
||||
assert memory_store.get_current_username() == "envuser"
|
||||
|
||||
|
||||
class TestIdentityProfile:
|
||||
def test_upsert_creates_profile(self, temp_db, user):
|
||||
profile = memory_store.upsert_identity_profile(
|
||||
user["id"], role="Engineer", preferences="Python first"
|
||||
)
|
||||
assert profile["role"] == "Engineer"
|
||||
assert profile["preferences"] == "Python first"
|
||||
|
||||
def test_upsert_partial_update_preserves_other_fields(self, temp_db, user):
|
||||
memory_store.upsert_identity_profile(
|
||||
user["id"], role="Eng", preferences="Python", pinned_facts="- fact 1"
|
||||
)
|
||||
# Only update role — preferences and pinned_facts should be preserved
|
||||
memory_store.upsert_identity_profile(user["id"], role="Senior Eng")
|
||||
profile = memory_store.get_identity_profile(user["id"])
|
||||
assert profile["role"] == "Senior Eng"
|
||||
assert profile["preferences"] == "Python"
|
||||
assert "fact 1" in profile["pinned_facts"]
|
||||
|
||||
def test_get_missing_profile_returns_none(self, temp_db):
|
||||
assert memory_store.get_identity_profile("nonexistent-id") is None
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_create_session_returns_id(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
assert sid and len(sid) == 36 # UUID format
|
||||
|
||||
def test_close_session(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "Built a thing", topics="mcp", outcome="Done")
|
||||
sessions = memory_store.get_recent_sessions(user["id"])
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["one_liner"] == "Built a thing"
|
||||
assert sessions[0]["topics"] == "mcp"
|
||||
|
||||
def test_one_liner_truncated_at_120_chars(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
long_title = "x" * 200
|
||||
memory_store.close_session(sid, long_title)
|
||||
sessions = memory_store.get_recent_sessions(user["id"])
|
||||
assert len(sessions[0]["one_liner"]) == 120
|
||||
|
||||
def test_open_session_not_in_recent(self, temp_db, user):
|
||||
memory_store.create_session(user["id"])
|
||||
# Open sessions (no ended_at) must NOT appear in the recent list
|
||||
assert memory_store.get_recent_sessions(user["id"]) == []
|
||||
|
||||
def test_save_session_summary(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "Headline")
|
||||
memory_store.save_session_summary(sid, "Detailed summary", key_facts="- fact")
|
||||
detail = memory_store.get_session_detail(sid)
|
||||
assert detail["summary"] == "Detailed summary"
|
||||
assert detail["key_facts"] == "- fact"
|
||||
# has_tier2 flag must be set
|
||||
sessions = memory_store.get_recent_sessions(user["id"])
|
||||
assert sessions[0]["has_tier2"] == 1
|
||||
|
||||
def test_get_open_sessions(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
open_s = memory_store.get_open_sessions(user["id"])
|
||||
assert any(s["id"] == sid for s in open_s)
|
||||
|
||||
|
||||
class TestChunks:
|
||||
def test_append_and_search_chunks(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.append_chunk(
|
||||
sid, user["id"], "assistant",
|
||||
"We decided to use SQLite for the database",
|
||||
"architectural decision"
|
||||
)
|
||||
results = memory_store.search_chunks(user["id"], "SQLite")
|
||||
assert len(results) == 1
|
||||
assert "SQLite" in results[0]["content"]
|
||||
|
||||
def test_chunk_seq_increments(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
id1 = memory_store.append_chunk(sid, user["id"], "user", "first")
|
||||
id2 = memory_store.append_chunk(sid, user["id"], "assistant", "second")
|
||||
assert id2 > id1
|
||||
|
||||
def test_search_isolated_by_user(self, temp_db):
|
||||
u1 = memory_store.get_or_create_user("user_a")
|
||||
u2 = memory_store.get_or_create_user("user_b")
|
||||
sid1 = memory_store.create_session(u1["id"])
|
||||
memory_store.append_chunk(sid1, u1["id"], "user", "secret data")
|
||||
# u2 should not see u1's chunks
|
||||
assert memory_store.search_chunks(u2["id"], "secret") == []
|
||||
|
||||
|
||||
class TestFacts:
|
||||
def test_store_and_retrieve_fact(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "preference", "Prefers dark mode")
|
||||
facts = memory_store.get_facts(user["id"])
|
||||
assert any(f["id"] == fid for f in facts)
|
||||
|
||||
def test_filter_by_category(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "preference", "Python first")
|
||||
memory_store.store_fact(user["id"], "decision", "Use SQLite")
|
||||
prefs = memory_store.get_facts(user["id"], category="preference")
|
||||
assert all(f["category"] == "preference" for f in prefs)
|
||||
|
||||
|
||||
class TestFactDeprecation:
|
||||
def test_deprecate_returns_true_on_success(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "codebase", "Old stack note")
|
||||
result = memory_store.deprecate_fact(fid, user["id"], "No longer applicable")
|
||||
assert result is True
|
||||
|
||||
def test_deprecate_hides_fact_from_get_facts(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "codebase", "Deprecated technology")
|
||||
memory_store.deprecate_fact(fid, user["id"])
|
||||
facts = memory_store.get_facts(user["id"])
|
||||
assert not any(f["id"] == fid for f in facts)
|
||||
|
||||
def test_deprecated_fact_visible_with_include_deprecated(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "codebase", "Soft-deleted fact")
|
||||
memory_store.deprecate_fact(fid, user["id"], "outdated")
|
||||
all_facts = memory_store.get_facts(user["id"], include_deprecated=True)
|
||||
assert any(f["id"] == fid for f in all_facts)
|
||||
|
||||
def test_deprecation_reason_stored(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "decision", "Use Gradle")
|
||||
memory_store.deprecate_fact(fid, user["id"], "Switched to Maven")
|
||||
all_facts = memory_store.get_facts(user["id"], include_deprecated=True)
|
||||
deprecated = next(f for f in all_facts if f["id"] == fid)
|
||||
assert deprecated["deprecation_reason"] == "Switched to Maven"
|
||||
assert deprecated["deprecated"] == 1
|
||||
|
||||
def test_deprecate_no_reason_still_works(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "preference", "Some preference")
|
||||
result = memory_store.deprecate_fact(fid, user["id"])
|
||||
assert result is True
|
||||
facts = memory_store.get_facts(user["id"])
|
||||
assert not any(f["id"] == fid for f in facts)
|
||||
|
||||
def test_deprecate_unknown_id_returns_false(self, temp_db, user):
|
||||
result = memory_store.deprecate_fact(99999, user["id"])
|
||||
assert result is False
|
||||
|
||||
def test_deprecate_wrong_user_returns_false(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("user_a")
|
||||
user_b = memory_store.get_or_create_user("user_b")
|
||||
fid = memory_store.store_fact(user_a["id"], "codebase", "User A's fact")
|
||||
result = memory_store.deprecate_fact(fid, user_b["id"], "Should not work")
|
||||
assert result is False
|
||||
# Fact must still be visible for user_a
|
||||
facts = memory_store.get_facts(user_a["id"])
|
||||
assert any(f["id"] == fid for f in facts)
|
||||
|
||||
def test_non_deprecated_facts_still_returned_by_default(self, temp_db, user):
|
||||
fid_keep = memory_store.store_fact(user["id"], "preference", "Keep this one")
|
||||
fid_drop = memory_store.store_fact(user["id"], "preference", "Drop this one")
|
||||
memory_store.deprecate_fact(fid_drop, user["id"])
|
||||
facts = memory_store.get_facts(user["id"])
|
||||
ids = [f["id"] for f in facts]
|
||||
assert fid_keep in ids
|
||||
assert fid_drop not in ids
|
||||
|
||||
|
||||
class TestStats:
|
||||
def test_stats_returns_expected_keys(self, temp_db, user):
|
||||
stats = memory_store.get_stats(user["id"])
|
||||
for key in ("sessions", "facts", "chunks", "db_size_kb", "db_path"):
|
||||
assert key in stats
|
||||
|
||||
|
||||
class TestHypotheses:
|
||||
def test_add_hypothesis_returns_id(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "I believe X because Y")
|
||||
assert isinstance(hid, int)
|
||||
assert hid > 0
|
||||
|
||||
def test_add_hypothesis_default_confidence(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Default confidence belief")
|
||||
results = memory_store.list_hypotheses(user["id"])
|
||||
assert results[0]["confidence"] == 0.7
|
||||
|
||||
def test_add_hypothesis_custom_confidence(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_hypothesis(user["id"], sid, "Strong belief", confidence=0.95)
|
||||
results = memory_store.list_hypotheses(user["id"])
|
||||
assert results[0]["confidence"] == 0.95
|
||||
|
||||
def test_new_hypothesis_status_is_open(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_hypothesis(user["id"], sid, "Fresh thought")
|
||||
results = memory_store.list_hypotheses(user["id"])
|
||||
assert results[0]["status"] == "open"
|
||||
|
||||
def test_list_hypotheses_returns_all(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_hypothesis(user["id"], sid, "Thought one")
|
||||
memory_store.add_hypothesis(user["id"], sid, "Thought two")
|
||||
results = memory_store.list_hypotheses(user["id"])
|
||||
assert len(results) == 2
|
||||
|
||||
def test_list_hypotheses_filter_by_status(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Will be confirmed")
|
||||
memory_store.add_hypothesis(user["id"], sid, "Stays open")
|
||||
memory_store.resolve_hypothesis(hid, user["id"], "confirmed", "It was true")
|
||||
open_list = memory_store.list_hypotheses(user["id"], status="open")
|
||||
confirmed_list = memory_store.list_hypotheses(user["id"], status="confirmed")
|
||||
assert len(open_list) == 1
|
||||
assert open_list[0]["hypothesis"] == "Stays open"
|
||||
assert len(confirmed_list) == 1
|
||||
assert confirmed_list[0]["hypothesis"] == "Will be confirmed"
|
||||
|
||||
def test_resolve_confirmed(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Bug is in serializer")
|
||||
result = memory_store.resolve_hypothesis(
|
||||
hid, user["id"], "confirmed", "Confirmed — the serializer had a null check missing"
|
||||
)
|
||||
assert result is True
|
||||
resolved = memory_store.list_hypotheses(user["id"], status="confirmed")
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0]["resolution"] == "Confirmed — the serializer had a null check missing"
|
||||
assert resolved[0]["resolved_at"] is not None
|
||||
|
||||
def test_resolve_refuted(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Problem is in the network")
|
||||
memory_store.resolve_hypothesis(hid, user["id"], "refuted", "Was actually a race condition")
|
||||
results = memory_store.list_hypotheses(user["id"], status="refuted")
|
||||
assert results[0]["status"] == "refuted"
|
||||
|
||||
def test_resolve_abandoned(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Might be a cache issue")
|
||||
memory_store.resolve_hypothesis(hid, user["id"], "abandoned")
|
||||
results = memory_store.list_hypotheses(user["id"], status="abandoned")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_resolve_invalid_status_raises(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Some thought")
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
memory_store.resolve_hypothesis(hid, user["id"], "wrong_status")
|
||||
|
||||
def test_resolve_unknown_id_returns_false(self, temp_db, user):
|
||||
result = memory_store.resolve_hypothesis(99999, user["id"], "confirmed")
|
||||
assert result is False
|
||||
|
||||
def test_resolve_wrong_user_returns_false(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("user_a")
|
||||
user_b = memory_store.get_or_create_user("user_b")
|
||||
sid = memory_store.create_session(user_a["id"])
|
||||
hid = memory_store.add_hypothesis(user_a["id"], sid, "User A's private thought")
|
||||
result = memory_store.resolve_hypothesis(hid, user_b["id"], "confirmed")
|
||||
assert result is False
|
||||
# Hypothesis must still be open for user_a
|
||||
results = memory_store.list_hypotheses(user_a["id"], status="open")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_list_isolated_by_user(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("user_a")
|
||||
user_b = memory_store.get_or_create_user("user_b")
|
||||
sid_a = memory_store.create_session(user_a["id"])
|
||||
memory_store.add_hypothesis(user_a["id"], sid_a, "User A only sees this")
|
||||
assert memory_store.list_hypotheses(user_b["id"]) == []
|
||||
|
||||
def test_resolve_no_resolution_text_is_allowed(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
hid = memory_store.add_hypothesis(user["id"], sid, "Quick thought")
|
||||
result = memory_store.resolve_hypothesis(hid, user["id"], "abandoned")
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestSearchFacts:
|
||||
def test_search_returns_matching_fact(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "codebase", "We use SQLite for local storage")
|
||||
results = memory_store.search_facts(user["id"], "SQLite")
|
||||
assert len(results) == 1
|
||||
assert "SQLite" in results[0]["fact"]
|
||||
|
||||
def test_search_porter_stemming(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "codebase", "FastMCP serialization rules")
|
||||
# 'serialize' should match 'serialization' via Porter stemmer
|
||||
results = memory_store.search_facts(user["id"], "serialize")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_no_results(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "preference", "Prefers dark mode")
|
||||
results = memory_store.search_facts(user["id"], "quantum")
|
||||
assert results == []
|
||||
|
||||
def test_search_excludes_deprecated(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "codebase", "Old deprecated SQLite note")
|
||||
memory_store.deprecate_fact(fid, user["id"])
|
||||
results = memory_store.search_facts(user["id"], "SQLite")
|
||||
assert results == []
|
||||
|
||||
def test_search_isolated_by_user(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("search_a")
|
||||
user_b = memory_store.get_or_create_user("search_b")
|
||||
memory_store.store_fact(user_a["id"], "codebase", "User A secret fact")
|
||||
assert memory_store.search_facts(user_b["id"], "secret") == []
|
||||
|
||||
def test_search_returns_category(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "preference", "Prefers uv over pip")
|
||||
results = memory_store.search_facts(user["id"], "uv")
|
||||
assert results[0]["category"] == "preference"
|
||||
|
||||
def test_search_limit(self, temp_db, user):
|
||||
for i in range(5):
|
||||
memory_store.store_fact(user["id"], "codebase", f"SQLite fact number {i}")
|
||||
results = memory_store.search_facts(user["id"], "SQLite", limit=3)
|
||||
assert len(results) == 3
|
||||
|
||||
def test_search_multiword_and_match(self, temp_db, user):
|
||||
# Regression: multi-word query must AND-match (both words anywhere),
|
||||
# NOT phrase-match (words consecutive). The 2026-03-31 fix accidentally
|
||||
# broke this by wrapping the whole query in one double-quoted phrase.
|
||||
memory_store.store_fact(user["id"], "codebase",
|
||||
"BigMind uses SQLite for persistent local storage")
|
||||
# Both words present but NOT consecutive — phrase search would return nothing
|
||||
results = memory_store.search_facts(user["id"], "SQLite persistent")
|
||||
assert len(results) == 1, "Multi-word AND search must match even when words are not consecutive"
|
||||
|
||||
def test_search_multiword_partial_match_returns_nothing(self, temp_db, user):
|
||||
# If only one of two required words is present, no match
|
||||
memory_store.store_fact(user["id"], "codebase", "We use SQLite locally")
|
||||
results = memory_store.search_facts(user["id"], "SQLite quantum")
|
||||
assert results == []
|
||||
|
||||
def test_search_reserved_word_category(self, temp_db, user):
|
||||
# Regression: FTS5 reserved words like 'rank', 'content', 'category'
|
||||
# must not crash or return wrong results
|
||||
memory_store.store_fact(user["id"], "codebase", "rank and content pipeline")
|
||||
results = memory_store.search_facts(user["id"], "rank")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_reserved_word_content(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "codebase", "the content table stores data")
|
||||
results = memory_store.search_facts(user["id"], "content")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_three_word_query(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "codebase",
|
||||
"parallel sessions across IDEs share the same SQLite database")
|
||||
results = memory_store.search_facts(user["id"], "parallel sessions SQLite")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
class TestSearchChunksMultiword:
|
||||
def test_chunk_multiword_and_match(self, temp_db, user):
|
||||
# Same regression test for search_chunks
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.append_chunk(sid, user["id"], "assistant",
|
||||
"WAL mode allows parallel reads from multiple IDE sessions",
|
||||
"architectural decision")
|
||||
# Words present but not consecutive — phrase search would fail
|
||||
results = memory_store.search_chunks(user["id"], "parallel IDE")
|
||||
assert len(results) == 1, "Multi-word AND search on chunks must not be a phrase search"
|
||||
|
||||
def test_chunk_reserved_word_rank(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.append_chunk(sid, user["id"], "assistant",
|
||||
"bm25 rank scores the relevance of results", None)
|
||||
results = memory_store.search_chunks(user["id"], "rank")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
class TestUpgradeRequests:
|
||||
def test_add_returns_id(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
rid = memory_store.add_upgrade_request(
|
||||
user["id"], sid, "Add FTS to facts", "Would speed up recall"
|
||||
)
|
||||
assert isinstance(rid, int) and rid > 0
|
||||
|
||||
def test_default_status_is_open(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
results = memory_store.list_upgrade_requests(user["id"])
|
||||
assert results[0]["status"] == "open"
|
||||
|
||||
def test_default_priority_is_medium(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
results = memory_store.list_upgrade_requests(user["id"])
|
||||
assert results[0]["priority"] == "medium"
|
||||
|
||||
def test_default_certainty(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
results = memory_store.list_upgrade_requests(user["id"])
|
||||
assert results[0]["certainty"] == 0.7
|
||||
|
||||
def test_custom_priority_and_certainty(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.add_upgrade_request(
|
||||
user["id"], sid, "Critical feature", "Blocks work",
|
||||
priority="high", certainty=0.95
|
||||
)
|
||||
results = memory_store.list_upgrade_requests(user["id"])
|
||||
assert results[0]["priority"] == "high"
|
||||
assert results[0]["certainty"] == 0.95
|
||||
|
||||
def test_list_filter_by_status(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
rid = memory_store.add_upgrade_request(user["id"], sid, "Feature A", "Reason A")
|
||||
memory_store.add_upgrade_request(user["id"], sid, "Feature B", "Reason B")
|
||||
memory_store.resolve_upgrade_request(rid, user["id"], "resolved", "Done")
|
||||
open_list = memory_store.list_upgrade_requests(user["id"], status="open")
|
||||
resolved_list = memory_store.list_upgrade_requests(user["id"], status="resolved")
|
||||
assert len(open_list) == 1 and open_list[0]["description"] == "Feature B"
|
||||
assert len(resolved_list) == 1 and resolved_list[0]["description"] == "Feature A"
|
||||
|
||||
def test_resolve_resolved(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
rid = memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
result = memory_store.resolve_upgrade_request(rid, user["id"], "resolved", "Shipped in v4")
|
||||
assert result is True
|
||||
results = memory_store.list_upgrade_requests(user["id"], status="resolved")
|
||||
assert results[0]["resolution"] == "Shipped in v4"
|
||||
assert results[0]["resolved_at"] is not None
|
||||
|
||||
def test_resolve_rejected(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
rid = memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
memory_store.resolve_upgrade_request(rid, user["id"], "rejected", "Out of scope")
|
||||
results = memory_store.list_upgrade_requests(user["id"], status="rejected")
|
||||
assert results[0]["status"] == "rejected"
|
||||
|
||||
def test_resolve_invalid_status_raises(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
rid = memory_store.add_upgrade_request(user["id"], sid, "Feature X", "Reason Y")
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
memory_store.resolve_upgrade_request(rid, user["id"], "done")
|
||||
|
||||
def test_resolve_unknown_id_returns_false(self, temp_db, user):
|
||||
assert memory_store.resolve_upgrade_request(99999, user["id"], "resolved") is False
|
||||
|
||||
def test_resolve_wrong_user_returns_false(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("user_a")
|
||||
user_b = memory_store.get_or_create_user("user_b")
|
||||
sid = memory_store.create_session(user_a["id"])
|
||||
rid = memory_store.add_upgrade_request(user_a["id"], sid, "Feature X", "Reason Y")
|
||||
assert memory_store.resolve_upgrade_request(rid, user_b["id"], "resolved") is False
|
||||
assert memory_store.list_upgrade_requests(user_a["id"], status="open")[0]["id"] == rid
|
||||
|
||||
def test_list_isolated_by_user(self, temp_db):
|
||||
user_a = memory_store.get_or_create_user("user_a")
|
||||
user_b = memory_store.get_or_create_user("user_b")
|
||||
sid = memory_store.create_session(user_a["id"])
|
||||
memory_store.add_upgrade_request(user_a["id"], sid, "Only A sees this", "Reason")
|
||||
assert memory_store.list_upgrade_requests(user_b["id"]) == []
|
||||
|
||||
|
||||
# ── Health Check ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestHealthCheck:
|
||||
def test_returns_expected_keys(self, temp_db, user):
|
||||
report = memory_store.health_check(user["id"])
|
||||
for key in (
|
||||
"stale_facts", "sessions_without_summary", "open_sessions",
|
||||
"chunk_count", "fts_row_count", "fts_in_sync",
|
||||
"low_confidence_facts", "stale_threshold_days",
|
||||
):
|
||||
assert key in report
|
||||
|
||||
def test_empty_db_no_issues(self, temp_db, user):
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert report["stale_facts"] == []
|
||||
assert report["sessions_without_summary"] == 0
|
||||
assert report["open_sessions"] == []
|
||||
assert report["fts_in_sync"] is True
|
||||
|
||||
def test_detects_stale_facts(self, temp_db, user):
|
||||
fid = memory_store.store_fact(user["id"], "codebase", "Old deployment note")
|
||||
old_date = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat()
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE facts SET updated_at=? WHERE id=?", (old_date, fid))
|
||||
report = memory_store.health_check(user["id"], stale_days=30)
|
||||
assert len(report["stale_facts"]) == 1
|
||||
assert report["stale_facts"][0]["id"] == fid
|
||||
|
||||
def test_fresh_facts_not_flagged(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "preference", "Fresh fact today")
|
||||
report = memory_store.health_check(user["id"], stale_days=30)
|
||||
assert report["stale_facts"] == []
|
||||
|
||||
def test_detects_sessions_without_summary(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "Closed without summary")
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert report["sessions_without_summary"] == 1
|
||||
|
||||
def test_sessions_with_summary_not_flagged(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "Closed with summary")
|
||||
memory_store.save_session_summary(sid, "Full narrative here.")
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert report["sessions_without_summary"] == 0
|
||||
|
||||
def test_detects_open_sessions(self, temp_db, user):
|
||||
memory_store.create_session(user["id"])
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert len(report["open_sessions"]) == 1
|
||||
|
||||
def test_detects_low_confidence_facts(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "codebase", "Uncertain thing", confidence=0.5)
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert len(report["low_confidence_facts"]) == 1
|
||||
assert report["low_confidence_facts"][0]["confidence"] == 0.5
|
||||
|
||||
def test_high_confidence_facts_not_flagged(self, temp_db, user):
|
||||
memory_store.store_fact(user["id"], "preference", "Certain thing", confidence=1.0)
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert report["low_confidence_facts"] == []
|
||||
|
||||
def test_fts_in_sync_after_append(self, temp_db, user):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.append_chunk(sid, user["id"], "user", "FTS test chunk")
|
||||
report = memory_store.health_check(user["id"])
|
||||
assert report["fts_in_sync"] is True
|
||||
assert report["chunk_count"] == 1
|
||||
assert report["fts_row_count"] == 1
|
||||
|
||||
def test_stale_threshold_stored_in_report(self, temp_db, user):
|
||||
report = memory_store.health_check(user["id"], stale_days=45)
|
||||
assert report["stale_threshold_days"] == 45
|
||||
|
||||
|
||||
class TestExportMemory:
|
||||
def test_creates_file(self, temp_db, user, tmp_path):
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
assert Path(out).exists()
|
||||
|
||||
def test_json_structure(self, temp_db, user, tmp_path):
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
for key in (
|
||||
"export_date", "bigmind_version", "user", "identity_profile",
|
||||
"facts", "sessions", "conversation_chunks", "stats",
|
||||
):
|
||||
assert key in data
|
||||
|
||||
def test_facts_included(self, temp_db, user, tmp_path):
|
||||
memory_store.store_fact(user["id"], "preference", "Exported preference fact")
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
assert any(f["fact"] == "Exported preference fact" for f in data["facts"])
|
||||
|
||||
def test_sessions_included_with_tier2(self, temp_db, user, tmp_path):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "Exported session")
|
||||
memory_store.save_session_summary(sid, "Full story for export test.")
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
exported = next(s for s in data["sessions"] if s["id"] == sid)
|
||||
assert exported["one_liner"] == "Exported session"
|
||||
assert exported["tier2_summary"]["summary"] == "Full story for export test."
|
||||
|
||||
def test_session_without_summary_has_null_tier2(self, temp_db, user, tmp_path):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(sid, "No narrative session")
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
exported = next(s for s in data["sessions"] if s["id"] == sid)
|
||||
assert exported["tier2_summary"] is None
|
||||
|
||||
def test_chunks_included(self, temp_db, user, tmp_path):
|
||||
sid = memory_store.create_session(user["id"])
|
||||
memory_store.append_chunk(sid, user["id"], "assistant", "Exported chunk content")
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
assert any("Exported chunk content" in c["content"] for c in data["conversation_chunks"])
|
||||
|
||||
def test_stats_accurate(self, temp_db, user, tmp_path):
|
||||
memory_store.store_fact(user["id"], "pref", "Fact A")
|
||||
memory_store.store_fact(user["id"], "pref", "Fact B")
|
||||
out = str(tmp_path / "export.json")
|
||||
memory_store.export_memory(user["id"], out)
|
||||
data = json.loads(Path(out).read_text())
|
||||
assert data["stats"]["facts_count"] == 2
|
||||
|
||||
def test_returns_result_dict(self, temp_db, user, tmp_path):
|
||||
out = str(tmp_path / "export.json")
|
||||
result = memory_store.export_memory(user["id"], out)
|
||||
assert result["output_path"] == out
|
||||
for key in ("facts_count", "sessions_count", "chunks_count", "file_size_kb"):
|
||||
assert key in result
|
||||
|
||||
def test_default_path_in_home_dir(self, temp_db, user):
|
||||
result = memory_store.export_memory(user["id"])
|
||||
path = Path(result["output_path"])
|
||||
try:
|
||||
assert path.exists()
|
||||
assert path.parent == Path.home()
|
||||
finally:
|
||||
path.unlink(missing_ok=True) # cleanup
|
||||
|
||||
|
||||
# ── Auto-close / Orphaned Sessions ────────────────────────────────────────────
|
||||
|
||||
class TestCloseOrphanedSessions:
|
||||
"""Tests for close_orphaned_sessions — the manual session cleanup tool."""
|
||||
|
||||
def test_closes_all_open_except_current(self, temp_db, user):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
s1 = memory_store.create_session(user["id"])
|
||||
s2 = memory_store.create_session(user["id"])
|
||||
s3 = memory_store.create_session(user["id"]) # "current"
|
||||
closed = close_orphaned_sessions(user["id"], keep_session_id=s3)
|
||||
assert set(closed) == {s1, s2}
|
||||
assert s3 not in closed
|
||||
|
||||
def test_closed_sessions_have_ended_at(self, temp_db, user):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
s1 = memory_store.create_session(user["id"])
|
||||
s2 = memory_store.create_session(user["id"]) # keep
|
||||
close_orphaned_sessions(user["id"], keep_session_id=s2)
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT ended_at, one_liner FROM sessions WHERE id=?", (s1,)
|
||||
).fetchone()
|
||||
assert row["ended_at"] is not None
|
||||
assert "orphaned" in row["one_liner"]
|
||||
|
||||
def test_keep_session_remains_open(self, temp_db, user):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
memory_store.create_session(user["id"])
|
||||
s_current = memory_store.create_session(user["id"])
|
||||
close_orphaned_sessions(user["id"], keep_session_id=s_current)
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT ended_at FROM sessions WHERE id=?", (s_current,)
|
||||
).fetchone()
|
||||
assert row["ended_at"] is None
|
||||
|
||||
def test_returns_empty_when_no_orphans(self, temp_db, user):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
s_current = memory_store.create_session(user["id"])
|
||||
assert close_orphaned_sessions(user["id"], keep_session_id=s_current) == []
|
||||
|
||||
def test_does_not_touch_already_closed_sessions(self, temp_db, user):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
s_old = memory_store.create_session(user["id"])
|
||||
memory_store.close_session(s_old, "Already closed")
|
||||
s_current = memory_store.create_session(user["id"])
|
||||
closed = close_orphaned_sessions(user["id"], keep_session_id=s_current)
|
||||
assert s_old not in closed
|
||||
|
||||
def test_isolated_by_user(self, temp_db):
|
||||
from bigmind.auto_close import close_orphaned_sessions
|
||||
u1 = memory_store.get_or_create_user("cleanup_user_a")
|
||||
u2 = memory_store.get_or_create_user("cleanup_user_b")
|
||||
memory_store.create_session(u1["id"])
|
||||
s_u1_current = memory_store.create_session(u1["id"])
|
||||
s_u2_active = memory_store.create_session(u2["id"])
|
||||
close_orphaned_sessions(u1["id"], keep_session_id=s_u1_current)
|
||||
still_open = memory_store.get_open_sessions(u2["id"])
|
||||
assert any(s["id"] == s_u2_active for s in still_open)
|
||||
|
||||
|
||||
# ── Restart Server ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRestartServer:
|
||||
"""Tests for restart_server_in_place — os.execv-based in-place restart."""
|
||||
|
||||
def test_calls_execv_with_correct_args(self, monkeypatch):
|
||||
"""restart_server_in_place must call os.execv(sys.executable, [sys.executable] + sys.argv)."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from bigmind.auto_close import restart_server_in_place
|
||||
|
||||
execv_calls = []
|
||||
monkeypatch.setattr(os, "execv", lambda path, args: execv_calls.append((path, args)))
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None) # skip the 500ms delay
|
||||
|
||||
restart_server_in_place()
|
||||
|
||||
assert len(execv_calls) == 1
|
||||
path, args = execv_calls[0]
|
||||
assert path == sys.executable
|
||||
assert args[0] == sys.executable
|
||||
assert args[1:] == sys.argv
|
||||
|
||||
def test_sleep_called_before_execv(self, monkeypatch):
|
||||
"""sleep must be called before execv so the MCP response is delivered first."""
|
||||
import os
|
||||
import time
|
||||
from bigmind.auto_close import restart_server_in_place
|
||||
|
||||
call_order = []
|
||||
monkeypatch.setattr(time, "sleep", lambda _: call_order.append("sleep"))
|
||||
monkeypatch.setattr(os, "execv", lambda *_: call_order.append("execv"))
|
||||
|
||||
restart_server_in_place()
|
||||
|
||||
assert call_order == ["sleep", "execv"]
|
||||
|
||||
def test_sleep_duration_is_half_second(self, monkeypatch):
|
||||
"""The delay must be 0.5s — long enough for the MCP response to be sent."""
|
||||
import os
|
||||
import time
|
||||
from bigmind.auto_close import restart_server_in_place
|
||||
|
||||
sleep_durations = []
|
||||
monkeypatch.setattr(time, "sleep", lambda d: sleep_durations.append(d))
|
||||
monkeypatch.setattr(os, "execv", lambda *_: None)
|
||||
|
||||
restart_server_in_place()
|
||||
|
||||
assert sleep_durations == [0.5]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user