792 lines
28 KiB
Python
792 lines
28 KiB
Python
import asyncio
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from memrelay.memory_service import build_context, rebuild_memory_curation_states
|
|
from memrelay.models import CuratedDocument, MemoryReference, UsageProfile
|
|
from tests.conftest import csrf_headers
|
|
from tests.fakes import FakeBasicMemory
|
|
|
|
|
|
def create_project(client: TestClient) -> str:
|
|
response = client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Memory Project"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
return response.json()["id"]
|
|
|
|
|
|
def save_memory(client: TestClient, payload: dict) -> dict:
|
|
response = client.post("/api/v1/memories", json=payload, headers=csrf_headers(client))
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
def test_api_timestamps_carry_utc_offset(initialized_client: TestClient) -> None:
|
|
# SQLite 丢弃时区信息,ORM 读回 naive 值直接 isoformat 会输出无偏移的时间串,
|
|
# 前端 new Date() 与 MCP 客户端会把它误当本地时间(显示偏差 8 小时)。
|
|
# UTCDateTime 类型必须保证 DB 往返后的序列化仍带 +00:00。
|
|
client = initialized_client
|
|
client.app.state.basic_memory = FakeBasicMemory()
|
|
save_memory(
|
|
client,
|
|
{
|
|
"request_id": "request-tz",
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": "Timezone check",
|
|
"content": "timestamps must stay UTC-aware",
|
|
},
|
|
)
|
|
listed = client.get("/api/v1/memories?scope=global").json()
|
|
record = next(item for item in listed if item["title"] == "Timezone check")
|
|
for field in ("created_at", "updated_at"):
|
|
value = record[field]
|
|
assert value.endswith("+00:00") or value.endswith("Z"), (field, value)
|
|
|
|
|
|
def test_memory_revision_idempotency_search_and_archive(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
payload = {
|
|
"request_id": "request-1",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"memory_type": "decision",
|
|
"title": "Database choice",
|
|
"content": "Use SQLite for system metadata.",
|
|
"tags": ["database"],
|
|
}
|
|
created = save_memory(client, payload)
|
|
assert created["revision"] == 1
|
|
assert fake.write_calls == 1
|
|
assert fake.notes[created["path"]]["metadata"]["stable_id"] == created["id"]
|
|
created_at = fake.notes[created["path"]]["metadata"]["created_at"]
|
|
|
|
repeated = save_memory(client, {**payload, "content": "ignored by idempotency"})
|
|
assert repeated == created
|
|
assert fake.write_calls == 1
|
|
|
|
conflict = client.post(
|
|
"/api/v1/memories",
|
|
json={
|
|
**payload,
|
|
"id": created["id"],
|
|
"request_id": "request-2",
|
|
"content": "Changed",
|
|
"expected_revision": 2,
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["error"]["code"] == "REVISION_CONFLICT"
|
|
|
|
updated = save_memory(
|
|
client,
|
|
{
|
|
**payload,
|
|
"id": created["id"],
|
|
"request_id": "request-3",
|
|
"content": "Use SQLite with WAL for metadata.",
|
|
"expected_revision": 1,
|
|
},
|
|
)
|
|
assert updated["revision"] == 2
|
|
assert fake.notes[updated["path"]]["metadata"]["created_at"] == created_at
|
|
assert (
|
|
client.get(f"/api/v1/memories/{created['id']}").json()["content"].startswith("Use SQLite")
|
|
)
|
|
|
|
search = client.get("/api/v1/memories/search", params={"query": "SQLite"}).json()
|
|
assert search["results"][0]["id"] == created["id"]
|
|
assert search["results"][0]["project_id"] == project_id
|
|
assert search["results"][0]["project_name"] == "Memory Project"
|
|
|
|
archived = client.post(
|
|
f"/api/v1/memories/{created['id']}/archive",
|
|
params={"expected_revision": 2},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert archived.status_code == 200
|
|
assert archived.json()["status"] == "archived"
|
|
deleted = client.delete(f"/api/v1/memories/{created['id']}", headers=csrf_headers(client))
|
|
assert deleted.status_code == 204
|
|
assert client.get(f"/api/v1/memories/{created['id']}").status_code == 404
|
|
|
|
|
|
def test_context_order_limit_and_semantic_fallback(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
global_rule = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "global-rule",
|
|
"scope": "global",
|
|
"memory_type": "rule",
|
|
"title": "Language",
|
|
"content": "Always reply in Chinese.",
|
|
},
|
|
)
|
|
project_fact = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "project-fact",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"memory_type": "fact",
|
|
"title": "Stack",
|
|
"content": "The backend uses FastAPI.",
|
|
},
|
|
)
|
|
fake.fail_semantic = True
|
|
context = client.post(
|
|
"/api/v1/memories/context",
|
|
json={"project_id": project_id, "query": "FastAPI", "max_chars": 4000},
|
|
)
|
|
assert context.status_code == 200
|
|
body = context.json()
|
|
assert body["semantic_degraded"] is True
|
|
assert body["included_memory_ids"][:2] == [global_rule["id"], project_fact["id"]]
|
|
assert body["content"].index("Always reply") < body["content"].index("FastAPI")
|
|
|
|
|
|
def test_project_search_includes_global_memory_unless_scope_is_explicit(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
global_rule = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "project-search-global-rule",
|
|
"scope": "global",
|
|
"memory_type": "rule",
|
|
"title": "Shared retrieval rule",
|
|
"content": "Shared project search marker for every workspace.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
document = CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=global_rule["usage_profile_id"],
|
|
document_type="profile",
|
|
title="Shared retrieval profile",
|
|
path="global/curated/shared-retrieval-profile",
|
|
source_hash="c" * 64,
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
fake.notes[document.path] = {
|
|
"title": document.title,
|
|
"content": "Shared project search marker in the consolidated profile.",
|
|
"permalink": document.path,
|
|
}
|
|
|
|
inherited = client.get(
|
|
"/api/v1/memories/search",
|
|
params={"query": "shared project search marker", "project_id": project_id},
|
|
)
|
|
assert inherited.status_code == 200
|
|
inherited_results = inherited.json()["results"]
|
|
assert {item["id"] for item in inherited_results} == {global_rule["id"], document.id}
|
|
assert {item["scope"] for item in inherited_results} == {"global"}
|
|
|
|
strict_project = client.get(
|
|
"/api/v1/memories/search",
|
|
params={
|
|
"query": "shared project search marker",
|
|
"project_id": project_id,
|
|
"scope": "project",
|
|
},
|
|
)
|
|
assert strict_project.status_code == 200
|
|
assert strict_project.json()["results"] == []
|
|
|
|
strict_global = client.get(
|
|
"/api/v1/memories/search",
|
|
params={
|
|
"query": "shared project search marker",
|
|
"project_id": project_id,
|
|
"scope": "global",
|
|
},
|
|
)
|
|
assert strict_global.status_code == 200
|
|
assert {item["id"] for item in strict_global.json()["results"]} == {
|
|
global_rule["id"],
|
|
document.id,
|
|
}
|
|
|
|
|
|
def test_context_includes_every_durable_memory_type_without_curation(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
memory_types = [
|
|
"rule",
|
|
"preference",
|
|
"fact",
|
|
"decision",
|
|
"experience",
|
|
"prd",
|
|
"task_list",
|
|
]
|
|
expected_ids: list[str] = []
|
|
for scope in ("global", "project"):
|
|
for memory_type in memory_types:
|
|
marker = f"{scope}-{memory_type}"
|
|
payload = {
|
|
"request_id": marker,
|
|
"scope": scope,
|
|
"memory_type": memory_type,
|
|
"title": marker,
|
|
"content": f"Durable context for {marker}.",
|
|
}
|
|
if scope == "project":
|
|
payload["project_id"] = project_id
|
|
expected_ids.append(save_memory(client, payload)["id"])
|
|
|
|
context = client.post(
|
|
"/api/v1/memories/context",
|
|
json={"project_id": project_id, "max_chars": 100_000},
|
|
)
|
|
|
|
assert context.status_code == 200
|
|
body = context.json()
|
|
assert body["included_memory_ids"] == expected_ids
|
|
for scope in ("global", "project"):
|
|
positions = [
|
|
body["content"].index(f"Durable context for {scope}-{item}") for item in memory_types
|
|
]
|
|
assert positions == sorted(positions)
|
|
|
|
|
|
def test_context_keeps_recent_progress_when_curated_documents_are_large(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
checkpoint = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "large-context-checkpoint",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"memory_type": "checkpoint",
|
|
"title": "Latest progress",
|
|
"content": "The latest checkpoint must remain visible.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
documents = [
|
|
CuratedDocument(
|
|
scope="project",
|
|
project_id=project_id,
|
|
document_type=document_type,
|
|
title=f"Large {document_type}",
|
|
path=f"projects/{project_id}/curated/{document_type}",
|
|
source_hash=str(index) * 64,
|
|
)
|
|
for index, document_type in enumerate(("architecture", "decisions"), start=1)
|
|
]
|
|
db.add_all(documents)
|
|
db.commit()
|
|
for document in documents:
|
|
fake.notes[document.path] = {
|
|
"title": document.title,
|
|
"content": f"{document.title}\n" + ("Long stable knowledge. " * 200),
|
|
"permalink": document.path,
|
|
}
|
|
|
|
context = client.post(
|
|
"/api/v1/memories/context",
|
|
json={"project_id": project_id, "max_chars": 1000},
|
|
)
|
|
|
|
assert context.status_code == 200
|
|
body = context.json()
|
|
assert body["truncated"] is True
|
|
assert body["included_curated_document_ids"] == [item.id for item in documents]
|
|
assert checkpoint["id"] in body["included_memory_ids"]
|
|
assert "The latest checkpoint must remain visible" in body["content"]
|
|
|
|
|
|
def test_memory_lifecycle_hides_curated_sources_without_deleting_history(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
memory = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "lifecycle-source",
|
|
"scope": "global",
|
|
"memory_type": "rule",
|
|
"title": "Lifecycle source",
|
|
"content": "Keep the original Markdown as historical evidence.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
document = CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=memory["usage_profile_id"],
|
|
document_type="profile",
|
|
title="Stable profile",
|
|
path="global/curated/profile",
|
|
source_hash="a" * 64,
|
|
metadata_json=json.dumps(
|
|
{
|
|
"cited_source_ids": [f"memory:{memory['id']}"],
|
|
"source_revisions": {f"memory:{memory['id']}": str(memory["revision"])},
|
|
"supersedes": [],
|
|
}
|
|
),
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
rebuild_memory_curation_states(db)
|
|
fake.notes[document.path] = {
|
|
"title": document.title,
|
|
"content": "Stable profile preserves historical evidence.",
|
|
"permalink": document.path,
|
|
}
|
|
|
|
assert client.get("/api/v1/memories", params={"scope": "global"}).json() == []
|
|
covered = client.get(
|
|
"/api/v1/memories",
|
|
params={"scope": "global", "lifecycle": "covered"},
|
|
).json()
|
|
assert covered[0]["id"] == memory["id"]
|
|
assert covered[0]["curation_status"] == "covered"
|
|
assert covered[0]["curated_revision"] == memory["revision"]
|
|
assert covered[0]["covered_by"] == [document.id]
|
|
assert memory["path"] in fake.notes
|
|
|
|
current_search = client.get(
|
|
"/api/v1/memories/search",
|
|
params={"query": "historical evidence"},
|
|
).json()["results"]
|
|
assert len(current_search) == 1
|
|
assert current_search[0]["id"] == document.id
|
|
assert current_search[0]["result_kind"] == "curated_document"
|
|
assert current_search[0]["read_tool"] == "curated_document_get"
|
|
assert current_search[0]["document_type"] == "profile"
|
|
pending_search = client.get(
|
|
"/api/v1/memories/search",
|
|
params={"query": "historical evidence", "lifecycle": "pending"},
|
|
).json()["results"]
|
|
assert pending_search == []
|
|
history_search = client.get(
|
|
"/api/v1/memories/search",
|
|
params={"query": "historical evidence", "lifecycle": "all"},
|
|
).json()
|
|
assert history_search["results"][0]["curation_status"] == "covered"
|
|
|
|
reset = client.post(
|
|
f"/api/v1/memories/{memory['id']}/pending",
|
|
params={"expected_revision": memory["revision"]},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert reset.status_code == 200
|
|
assert reset.json()["curation_status"] == "pending"
|
|
assert (
|
|
client.get("/api/v1/memories", params={"scope": "global"}).json()[0]["id"] == memory["id"]
|
|
)
|
|
|
|
|
|
def test_legacy_memory_without_profile_is_covered_by_profile_scoped_document(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
# 生产回归:早于记忆空间功能创建的全局记忆 usage_profile_id 为 NULL,对所有
|
|
# 空间可见并会被整理引用,但旧覆盖匹配不认 NULL,被引用后仍停留在待整理。
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
memory = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "legacy-profile-source",
|
|
"scope": "global",
|
|
"memory_type": "rule",
|
|
"title": "Legacy rule",
|
|
"content": "Created before usage profiles existed.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
reference = db.get(MemoryReference, memory["id"])
|
|
reference.usage_profile_id = None
|
|
document = CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=memory["usage_profile_id"],
|
|
document_type="profile",
|
|
title="Stable profile",
|
|
path="global/curated/legacy-profile",
|
|
source_hash="c" * 64,
|
|
metadata_json=json.dumps(
|
|
{
|
|
"cited_source_ids": [f"memory:{memory['id']}"],
|
|
"source_revisions": {f"memory:{memory['id']}": str(memory["revision"])},
|
|
"supersedes": [],
|
|
}
|
|
),
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
counts = rebuild_memory_curation_states(db)
|
|
assert counts["covered"] == 1
|
|
|
|
covered = client.get(
|
|
"/api/v1/memories",
|
|
params={"scope": "global", "lifecycle": "covered"},
|
|
).json()
|
|
assert covered[0]["id"] == memory["id"]
|
|
assert covered[0]["covered_reason"] == "cited"
|
|
|
|
|
|
def test_editing_a_curated_memory_returns_it_to_pending(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
memory = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "curated-edit-source",
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": "Curated fact",
|
|
"content": "Original fact.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
document = CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=memory["usage_profile_id"],
|
|
document_type="profile",
|
|
title="Stable profile",
|
|
path="global/curated/edit-profile",
|
|
source_hash="b" * 64,
|
|
metadata_json=json.dumps(
|
|
{
|
|
"cited_source_ids": [f"memory:{memory['id']}"],
|
|
"source_revisions": {f"memory:{memory['id']}": "1"},
|
|
"supersedes": [],
|
|
}
|
|
),
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
rebuild_memory_curation_states(db)
|
|
|
|
updated = save_memory(
|
|
client,
|
|
{
|
|
"id": memory["id"],
|
|
"request_id": "curated-edit-update",
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": "Curated fact",
|
|
"content": "Updated fact.",
|
|
"expected_revision": 1,
|
|
},
|
|
)
|
|
assert updated["revision"] == 2
|
|
assert updated["curation_status"] == "pending"
|
|
assert updated["curated_revision"] is None
|
|
assert updated["covered_by"] == []
|
|
|
|
|
|
def test_context_keeps_personal_preferences_in_the_current_usage_profile(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
alice = client.post(
|
|
"/api/v1/curation/profiles",
|
|
json={"name": "alice", "description": "Alice preferences"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
bob = client.post(
|
|
"/api/v1/curation/profiles",
|
|
json={"name": "bob", "description": "Bob preferences"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
alice_memory = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "alice-preference",
|
|
"scope": "global",
|
|
"usage_profile_id": alice["id"],
|
|
"memory_type": "preference",
|
|
"title": "Alice editor",
|
|
"content": "Alice prefers compact editor layouts.",
|
|
},
|
|
)
|
|
bob_memory = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "bob-preference",
|
|
"scope": "global",
|
|
"usage_profile_id": bob["id"],
|
|
"memory_type": "preference",
|
|
"title": "Bob editor",
|
|
"content": "Bob prefers spacious editor layouts.",
|
|
},
|
|
)
|
|
project_fact = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "shared-project-fact",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"usage_profile_id": alice["id"],
|
|
"memory_type": "fact",
|
|
"title": "Shared stack",
|
|
"content": "The project uses FastAPI.",
|
|
},
|
|
)
|
|
|
|
with client.app.state.session_factory() as db:
|
|
alice_context = asyncio.run(build_context(db, fake, project_id, None, 4000, alice["id"]))
|
|
with client.app.state.session_factory() as db:
|
|
bob_context = asyncio.run(build_context(db, fake, project_id, None, 4000, bob["id"]))
|
|
|
|
assert alice_memory["id"] in alice_context.included_memory_ids
|
|
assert bob_memory["id"] not in alice_context.included_memory_ids
|
|
assert bob_memory["id"] in bob_context.included_memory_ids
|
|
assert alice_memory["id"] not in bob_context.included_memory_ids
|
|
assert project_fact["id"] in alice_context.included_memory_ids
|
|
assert project_fact["id"] in bob_context.included_memory_ids
|
|
assert fake.notes[alice_memory["path"]]["metadata"]["usage_profile_id"] == alice["id"]
|
|
|
|
|
|
def test_context_includes_only_the_current_workspace_scenario_documents(
|
|
initialized_client: TestClient,
|
|
) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project = client.post(
|
|
"/api/v1/projects",
|
|
json={"name": "Development context", "workspace_type": "development"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
alice = client.post(
|
|
"/api/v1/curation/profiles",
|
|
json={"name": "context-alice", "description": "Alice context"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
bob = client.post(
|
|
"/api/v1/curation/profiles",
|
|
json={"name": "context-bob", "description": "Bob context"},
|
|
headers=csrf_headers(client),
|
|
).json()
|
|
with client.app.state.session_factory() as db:
|
|
shared = db.scalar(select(UsageProfile).where(UsageProfile.is_shared.is_(True)))
|
|
assert shared is not None
|
|
documents = [
|
|
CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=shared.id,
|
|
document_type="preferences",
|
|
title="Shared preferences",
|
|
path="global/curated/shared/preferences",
|
|
source_hash="1" * 64,
|
|
),
|
|
CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=alice["id"],
|
|
document_type="context_development",
|
|
title="Alice development",
|
|
path="global/curated/alice/context-development",
|
|
source_hash="2" * 64,
|
|
),
|
|
CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=alice["id"],
|
|
document_type="context_office",
|
|
title="Alice office",
|
|
path="global/curated/alice/context-office",
|
|
source_hash="3" * 64,
|
|
),
|
|
CuratedDocument(
|
|
scope="global",
|
|
usage_profile_id=bob["id"],
|
|
document_type="context_development",
|
|
title="Bob development",
|
|
path="global/curated/bob/context-development",
|
|
source_hash="4" * 64,
|
|
),
|
|
CuratedDocument(
|
|
scope="project",
|
|
project_id=project["id"],
|
|
document_type="overview",
|
|
title="Workspace overview",
|
|
path=f"projects/{project['id']}/curated/overview",
|
|
source_hash="5" * 64,
|
|
),
|
|
]
|
|
db.add_all(documents)
|
|
db.commit()
|
|
document_ids = {item.title: item.id for item in documents}
|
|
for title, path in {
|
|
"Shared preferences": "global/curated/shared/preferences",
|
|
"Alice development": "global/curated/alice/context-development",
|
|
"Alice office": "global/curated/alice/context-office",
|
|
"Bob development": "global/curated/bob/context-development",
|
|
"Workspace overview": f"projects/{project['id']}/curated/overview",
|
|
}.items():
|
|
fake.notes[path] = {
|
|
"title": title,
|
|
"content": f"# {title}\n\n{title} body",
|
|
"permalink": path,
|
|
}
|
|
|
|
with client.app.state.session_factory() as db:
|
|
context = asyncio.run(build_context(db, fake, project["id"], None, 10_000, alice["id"]))
|
|
|
|
assert document_ids["Shared preferences"] in context.included_curated_document_ids
|
|
assert document_ids["Alice development"] in context.included_curated_document_ids
|
|
assert document_ids["Workspace overview"] in context.included_curated_document_ids
|
|
assert document_ids["Alice office"] not in context.included_curated_document_ids
|
|
assert document_ids["Bob development"] not in context.included_curated_document_ids
|
|
|
|
|
|
def test_project_scope_validation(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
client.app.state.basic_memory = FakeBasicMemory()
|
|
response = client.post(
|
|
"/api/v1/memories",
|
|
json={
|
|
"request_id": "invalid-scope",
|
|
"scope": "project",
|
|
"memory_type": "fact",
|
|
"title": "Invalid",
|
|
"content": "Missing project.",
|
|
},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert response.status_code == 422
|
|
assert response.json()["error"]["code"] == "MEMORY_SCOPE_INVALID"
|
|
|
|
|
|
def test_memory_title_can_be_renamed(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
created = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "rename-create",
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": "Old title",
|
|
"content": "Original content",
|
|
},
|
|
)
|
|
old_path = created["path"]
|
|
renamed = save_memory(
|
|
client,
|
|
{
|
|
"id": created["id"],
|
|
"request_id": "rename-update",
|
|
"scope": "global",
|
|
"memory_type": "fact",
|
|
"title": "New title",
|
|
"content": "Original content",
|
|
"expected_revision": 1,
|
|
},
|
|
)
|
|
assert renamed["title"] == "New title"
|
|
assert renamed["revision"] == 2
|
|
assert renamed["path"] != old_path
|
|
assert old_path not in fake.notes
|
|
assert renamed["path"] in fake.notes
|
|
|
|
|
|
def test_project_export_contains_timeline_and_markdown(initialized_client: TestClient) -> None:
|
|
client = initialized_client
|
|
fake = FakeBasicMemory()
|
|
client.app.state.basic_memory = fake
|
|
project_id = create_project(client)
|
|
save_memory(
|
|
client,
|
|
{
|
|
"request_id": "export-global",
|
|
"scope": "global",
|
|
"memory_type": "rule",
|
|
"title": "Language",
|
|
"content": "Always reply in Chinese.",
|
|
},
|
|
)
|
|
checkpoint = save_memory(
|
|
client,
|
|
{
|
|
"request_id": "export-checkpoint",
|
|
"scope": "project",
|
|
"project_id": project_id,
|
|
"memory_type": "checkpoint",
|
|
"title": "Implemented export",
|
|
"content": "## 完成内容\n\nAdded direct ZIP export.",
|
|
},
|
|
)
|
|
with client.app.state.session_factory() as db:
|
|
curated = CuratedDocument(
|
|
scope="project",
|
|
project_id=project_id,
|
|
document_type="overview",
|
|
title="Current project overview",
|
|
path=f"projects/{project_id}/curated/overview",
|
|
revision=2,
|
|
source_hash="e" * 64,
|
|
)
|
|
db.add(curated)
|
|
db.commit()
|
|
fake.notes[curated.path] = {
|
|
"title": curated.title,
|
|
"content": "The current consolidated project overview.",
|
|
"permalink": curated.path,
|
|
}
|
|
|
|
prepared = client.get(f"/api/v1/projects/{project_id}/export")
|
|
assert prepared.status_code == 200
|
|
path = urlsplit(prepared.json()["url"])
|
|
downloaded = client.get(f"{path.path}?{path.query}")
|
|
assert downloaded.status_code == 200
|
|
assert downloaded.headers["cache-control"] == "no-store"
|
|
|
|
with zipfile.ZipFile(io.BytesIO(downloaded.content)) as archive:
|
|
names = archive.namelist()
|
|
assert "aidocs/project_context.md" in names
|
|
assert "aidocs/manifest.json" in names
|
|
assert "aidocs/curated/project/overview.md" in names
|
|
assert any(checkpoint["id"] in name for name in names)
|
|
timeline = archive.read("aidocs/project_context.md").decode()
|
|
assert "Implemented export" in timeline
|
|
assert "Added direct ZIP export" in timeline
|
|
assert "curated/project/overview.md" in timeline
|
|
curated_content = archive.read("aidocs/curated/project/overview.md").decode()
|
|
assert "The current consolidated project overview" in curated_content
|
|
assert "revision: 2" in curated_content
|
|
manifest = json.loads(archive.read("aidocs/manifest.json"))
|
|
assert manifest["project"]["id"] == project_id
|
|
assert manifest["project_curated_documents"][0]["id"] == curated.id
|