from __future__ import annotations import base64 import json import logging from pathlib import Path from typing import Any import httpx import pytest from core.api import create_app from core.config import GatewayConfig from core.db import init_db from core.repository import MemoryRepository import core.api as api_module import core.service as service_module class FakeBackendClient: def __init__( self, search_results: list[dict[str, Any]] | None = None, search_data: dict[str, list[dict[str, Any]]] | None = None, health_error: Exception | None = None, add_failures: int = 0, flush_failures: int = 0, ) -> None: self.add_calls: list[dict[str, Any]] = [] self.flush_calls: list[dict[str, str]] = [] self.search_calls: list[dict[str, Any]] = [] self.search_results = search_results or [] self.search_data = search_data self.health_error = health_error self.add_failures = add_failures self.flush_failures = flush_failures async def add_memory(self, payload: dict[str, Any]) -> dict[str, Any]: self.add_calls.append(payload) if self.add_failures > 0: self.add_failures -= 1 raise RuntimeError("temporary add failure") return {"request_id": "add", "data": {"status": "accumulated"}} async def flush_memory( self, session_id: str, app_id: str, project_id: str, ) -> dict[str, Any]: self.flush_calls.append( {"session_id": session_id, "app_id": app_id, "project_id": project_id} ) if self.flush_failures > 0: self.flush_failures -= 1 raise RuntimeError("temporary flush failure") return {"request_id": "flush", "data": {"status": "extracted"}} async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]: self.search_calls.append(payload) data = self.search_data or {"episodes": self.search_results} return {"request_id": "search", "data": data} async def health_check(self) -> dict[str, Any]: if self.health_error is not None: raise self.health_error return {"status": "ok"} @pytest.fixture def config(tmp_path: Path) -> GatewayConfig: return GatewayConfig( backend_base_url="http://backend.test", database_path=tmp_path / "gateway.sqlite3", storage_dir=tmp_path / "storage", ) @pytest.fixture def repo(config: GatewayConfig) -> MemoryRepository: init_db(config.database_path) return MemoryRepository(config.database_path) def app_client( config: GatewayConfig, backend_client: FakeBackendClient, ) -> httpx.AsyncClient: app = create_app(config=config, backend_client=backend_client) transport = httpx.ASGITransport(app=app) return httpx.AsyncClient(transport=transport, base_url="http://test") def create_test_resource( repo: MemoryRepository, *, resource_id: str, user_id: str, uri: str = "file:///private/a.txt", ) -> None: repo.create_resource( id=resource_id, user_id=user_id, app_id="default", project_id="default", session_id=f"resource:{user_id}:{resource_id}", original_filename="a.txt", mime_type="text/plain", content_type="text", uri=uri, uri_public=False, sha256=f"sha-{resource_id}", size_bytes=1, title=None, description=None, status="extracted", error_message=None, ) def test_attachment_repository_deduplicates_and_lists_by_user_session( repo: MemoryRepository, ) -> None: values = { "user_id": "u_123", "app_id": "default", "project_id": "default", "session_id": "chat:c_1", "resource_id": None, "content_type": "image", "name": "picture.png", "internal_uri": "file:///private/picture.png", "source": "memory_add_uri", "sha256": None, } first = repo.create_attachment(**values) second = repo.create_attachment(**values) assert second["id"] == first["id"] assert repo.list_attachments_for_session("u_123", "chat:c_1") == [first] assert repo.list_attachments_for_session("other", "chat:c_1") == [] def test_soft_delete_resource_also_soft_deletes_attachments( repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="u_123") repo.create_attachment( user_id="u_123", app_id="default", project_id="default", session_id="resource:u_123:r_1", resource_id="r_1", content_type="text", name="a.txt", internal_uri="file:///private/a.txt", source="resource_upload", sha256="sha-r_1", ) repo.soft_delete_resource("r_1", "u_123") assert repo.list_attachments_for_session("u_123", "resource:u_123:r_1") == [] def test_soft_delete_resource_does_not_delete_other_users_attachments( repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="alice") repo.create_attachment( user_id="alice", app_id="default", project_id="default", session_id="resource:alice:r_1", resource_id="r_1", content_type="text", name="a.txt", internal_uri="file:///private/a.txt", source="resource_upload", sha256="sha-r_1", ) repo.soft_delete_resource("r_1", "bob") attachments = repo.list_attachments_for_session("alice", "resource:alice:r_1") assert len(attachments) == 1 async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str: response = await client.post("/users", json={"user_id": user_id}) assert response.status_code == 200, response.text body = response.json() assert body["user_id"] == user_id assert body["user_key"] return body["user_key"] def test_create_app_uses_configured_backend_timeout(config: GatewayConfig) -> None: config = GatewayConfig( backend_base_url=config.backend_base_url, database_path=config.database_path, storage_dir=config.storage_dir, backend_timeout_seconds=7.5, ) app = create_app(config=config) assert app.state.backend_client.timeout == 7.5 def test_create_app_uses_project_name(config: GatewayConfig) -> None: app = create_app(config=config, backend_client=FakeBackendClient()) assert app.title == "memory-gateway" def test_api_log_body_capture_policy_skips_large_and_multipart_requests() -> None: assert api_module._should_capture_request_body("application/json", 100) assert not api_module._should_capture_request_body( "application/json", api_module.MAX_LOG_BODY_BYTES + 1, ) assert not api_module._should_capture_request_body( "multipart/form-data; boundary=test", 100, ) @pytest.mark.asyncio async def test_api_logs_request_time_address_input_and_output( config: GatewayConfig, caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.INFO, logger="memory_gateway.api") backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "u_log") response = await client.get( "/resources", params={"user_id": "u_log", "user_key": user_key}, ) assert response.status_code == 200, response.text events = [json.loads(record.message) for record in caplog.records] create_user_event = next(item for item in events if item["path"] == "/users") assert create_user_event["output"]["body"]["user_key"] == "[REDACTED]" event = next(item for item in events if item["path"] == "/resources") assert event["request_time"] assert event["method"] == "GET" assert event["url"] == "http://test/resources?user_id=u_log&user_key=[REDACTED]" assert event["client"] == "127.0.0.1" assert event["duration_ms"] >= 0 assert event["input"]["query_params"] == { "user_id": "u_log", "user_key": "[REDACTED]", } assert event["output"]["status_code"] == 200 assert event["output"]["body"] == {"resources": []} @pytest.mark.asyncio async def test_api_logs_do_not_expose_secrets_from_large_json_bodies( config: GatewayConfig, caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.INFO, logger="memory_gateway.api") backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "u_large_log") caplog.clear() response = await client.post( "/memories/add", json={ "user_id": "u_large_log", "user_key": user_key, "session_id": "chat:c_large_log", "messages": [ { "sender_id": "u_large_log", "role": "user", "timestamp": 1234567890123, "content": "x" * 5000, } ], }, ) assert response.status_code == 200, response.text event = next( json.loads(record.message) for record in caplog.records if json.loads(record.message)["path"] == "/memories/add" ) assert event["input"]["body"]["truncated"] is True assert user_key not in json.dumps(event, ensure_ascii=False) @pytest.mark.asyncio async def test_health_reports_api_and_backend_ok( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: response = await client.get("/health") assert response.status_code == 200, response.text assert response.json() == { "status": "ok", "api": {"status": "ok"}, "backend": { "status": "ok", "base_url": "http://backend.test", "data": {"status": "ok"}, }, } @pytest.mark.asyncio async def test_health_reports_degraded_when_backend_fails( config: GatewayConfig, ) -> None: backend = FakeBackendClient(health_error=RuntimeError("backend down")) async with app_client(config, backend) as client: response = await client.get("/health") assert response.status_code == 200, response.text body = response.json() assert body["status"] == "degraded" assert body["api"] == {"status": "ok"} assert body["backend"]["status"] == "unavailable" assert body["backend"]["base_url"] == "http://backend.test" assert body["backend"]["error"] == "backend down" @pytest.mark.asyncio async def test_create_user_generates_and_persists_user_key( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "u_123") user = repo.get_user("u_123") assert user is not None assert user["user_key"] == user_key @pytest.mark.asyncio async def test_upload_resource_creates_record_and_calls_backend( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key, "title": "Contract"}, files={"file": ("contract.txt", b"pay in 30 days", "text/plain")}, ) assert response.status_code == 200, response.text body = response.json() resource_id = body["resource_id"] assert body["session_id"] == f"resource:u_123:{resource_id}" assert body["uri"] == f"resource://u_123/{resource_id}" assert body["status"] == "extracted" repo = MemoryRepository(config.database_path) resource = repo.get_resource(resource_id) assert resource is not None assert resource["status"] == "extracted" assert resource["original_filename"] == "contract.txt" assert resource["content_type"] == "text" assert resource["sha256"] assert resource["size_bytes"] == len(b"pay in 30 days") assert not resource["uri"].startswith("resource://") assert len(backend.add_calls) == 1 add_payload = backend.add_calls[0] assert add_payload["session_id"] == f"resource:u_123:{resource_id}" content = add_payload["messages"][0]["content"][0] assert content["type"] == "text" assert content["text"] == "pay in 30 days" assert "uri" not in content assert content["extras"] == {"resource_id": resource_id, "source": "user_upload"} assert backend.flush_calls == [ { "session_id": f"resource:u_123:{resource_id}", "app_id": "default", "project_id": "default", } ] @pytest.mark.asyncio async def test_upload_binary_resource_sends_base64_content_to_backend( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("paper.pdf", b"%PDF bytes", "application/pdf")}, ) assert response.status_code == 200, response.text content = backend.add_calls[0]["messages"][0]["content"][0] assert content["type"] == "pdf" assert content["base64"] == base64.b64encode(b"%PDF bytes").decode("ascii") assert content["ext"] == "pdf" assert content["name"] == "paper.pdf" assert "uri" not in content @pytest.mark.asyncio async def test_upload_resource_creates_attachment_mapping( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("picture.png", b"png bytes", "image/png")}, ) assert response.status_code == 200, response.text session_id = response.json()["session_id"] attachments = repo.list_attachments_for_session("u_123", session_id) assert len(attachments) == 1 assert attachments[0]["resource_id"] == response.json()["resource_id"] assert attachments[0]["content_type"] == "image" assert attachments[0]["name"] == "picture.png" assert attachments[0]["internal_uri"].startswith("file://") assert attachments[0]["source"] == "resource_upload" @pytest.mark.asyncio async def test_upload_resource_uses_current_timestamp( config: GatewayConfig, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( service_module, "current_timestamp_ms", lambda: 1234567890123, raising=False, ) backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("timed.txt", b"time me", "text/plain")}, ) assert response.status_code == 200, response.text assert backend.add_calls[0]["messages"][0]["timestamp"] == 1234567890123 @pytest.mark.asyncio async def test_upload_retries_transient_backend_failure( config: GatewayConfig, ) -> None: config = GatewayConfig( backend_base_url=config.backend_base_url, database_path=config.database_path, storage_dir=config.storage_dir, backend_ingest_attempts=2, backend_retry_delay_seconds=0, ) backend = FakeBackendClient(add_failures=1, flush_failures=1) async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("retry.txt", b"retry me", "text/plain")}, ) assert response.status_code == 200, response.text assert response.json()["status"] == "extracted" assert len(backend.add_calls) == 2 assert len(backend.flush_calls) == 2 @pytest.mark.asyncio async def test_upload_duplicate_resource_is_idempotent_for_same_user( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) first = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("same.txt", b"same bytes", "text/plain")}, ) second = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("same.txt", b"same bytes", "text/plain")}, ) assert first.status_code == 200, first.text assert second.status_code == 200, second.text assert second.json()["resource_id"] == first.json()["resource_id"] assert len(backend.add_calls) == 1 assert len(backend.flush_calls) == 1 @pytest.mark.asyncio async def test_upload_rejects_file_larger_than_configured_limit( config: GatewayConfig, repo: MemoryRepository, ) -> None: config = GatewayConfig( backend_base_url=config.backend_base_url, database_path=config.database_path, storage_dir=config.storage_dir, max_upload_bytes=4, ) backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("big.txt", b"too large", "text/plain")}, ) assert response.status_code == 413, response.text assert repo.list_resources("u_123") == [] assert not any(config.storage_dir.rglob("*")) assert backend.add_calls == [] @pytest.mark.asyncio async def test_upload_rejects_unsupported_mime_type( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("payload.bin", b"\x00\x01", "application/octet-stream")}, ) assert response.status_code == 415, response.text assert repo.list_resources("u_123") == [] assert backend.add_calls == [] @pytest.mark.asyncio async def test_resource_detail_does_not_leak_internal_uri( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) created = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("note.txt", b"hello", "text/plain")}, ) resource_id = created.json()["resource_id"] detail = await client.get( f"/resources/{resource_id}", params={"user_id": "u_123", "user_key": user_key}, ) assert detail.status_code == 200, detail.text resources = detail.json()["resources"] assert len(resources) == 1 assert resources[0]["uri"] == f"resource://u_123/{resource_id}" assert "file://" not in detail.text @pytest.mark.asyncio async def test_resource_detail_returns_empty_when_user_has_no_resource( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "u_empty") response = await client.get( "/resources/r_missing", params={"user_id": "u_empty", "user_key": user_key}, ) assert response.status_code == 200, response.text assert response.json() == {"resources": []} @pytest.mark.asyncio async def test_resources_are_isolated_by_user_key( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: alice_key = await create_user(client, "alice") bob_key = await create_user(client, "bob") created = await client.post( "/resources", data={"user_id": "alice", "user_key": alice_key}, files={"file": ("alice.txt", b"alice-only", "text/plain")}, ) resource_id = created.json()["resource_id"] bob_detail = await client.get( f"/resources/{resource_id}", params={"user_id": "bob", "user_key": bob_key}, ) bob_list = await client.get( "/resources", params={"user_id": "bob", "user_key": bob_key}, ) assert bob_detail.status_code == 200, bob_detail.text assert bob_detail.json() == {"resources": []} assert bob_list.status_code == 200, bob_list.text assert bob_list.json() == {"resources": []} @pytest.mark.asyncio async def test_resource_api_rejects_invalid_user_key( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: await create_user(client, "u_123") response = await client.get( "/resources", params={"user_id": "u_123", "user_key": "wrong"}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_add_memory_forwards_multimodal_payload_to_backend( config: GatewayConfig, ) -> None: backend = FakeBackendClient() audio = base64.b64encode(b"wav bytes").decode("ascii") content = [ {"type": "text", "text": "remember the picture and audio"}, {"type": "audio", "base64": audio, "ext": "wav", "name": "tone.wav"}, { "type": "image", "uri": "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png", "ext": "png", "name": "simple-multimodal-image.png", }, ] async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/add", json={ "user_id": "u_123", "user_key": user_key, "session_id": "chat:c_multimodal", "app_id": "default", "project_id": "default", "messages": [ { "sender_id": "u_123", "role": "user", "timestamp": 1234567890123, "content": content, } ], }, ) assert response.status_code == 200, response.text assert response.json() == { "session_id": "chat:c_multimodal", "backend": {"request_id": "add", "data": {"status": "accumulated"}}, } assert backend.add_calls == [ { "session_id": "chat:c_multimodal", "app_id": "default", "project_id": "default", "messages": [ { "sender_id": "u_123", "role": "user", "timestamp": 1234567890123, "content": content, } ], } ] @pytest.mark.asyncio async def test_add_memory_creates_uri_attachment_mapping( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() uri = "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png" async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/add", json={ "user_id": "u_123", "user_key": user_key, "session_id": "chat:c_uri", "messages": [ { "sender_id": "u_123", "role": "user", "timestamp": 1234567890123, "content": [ { "type": "image", "uri": uri, "name": "simple-multimodal-image.png", "ext": "png", } ], } ], }, ) assert response.status_code == 200, response.text attachments = repo.list_attachments_for_session("u_123", "chat:c_uri") assert [(item["name"], item["internal_uri"], item["source"]) for item in attachments] == [ ("simple-multimodal-image.png", uri, "memory_add_uri") ] assert backend.add_calls[0]["messages"][0]["content"][0]["uri"] == uri @pytest.mark.asyncio async def test_add_memory_materializes_base64_attachment( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() encoded = base64.b64encode(b"wav bytes").decode("ascii") async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/add", json={ "user_id": "u_123", "user_key": user_key, "session_id": "chat:c_base64", "messages": [ { "sender_id": "u_123", "role": "user", "timestamp": 1234567890123, "content": [ { "type": "audio", "base64": encoded, "name": "tone.wav", "ext": "wav", } ], } ], }, ) assert response.status_code == 200, response.text attachments = repo.list_attachments_for_session("u_123", "chat:c_base64") assert len(attachments) == 1 attachment = attachments[0] assert attachment["name"] == "tone.wav" assert attachment["source"] == "memory_add_base64" path = Path(attachment["internal_uri"].removeprefix("file://")) assert path.read_bytes() == b"wav bytes" assert backend.add_calls[0]["messages"][0]["content"][0]["base64"] == encoded @pytest.mark.asyncio async def test_add_memory_deduplicates_retried_base64_attachment( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() encoded = base64.b64encode(b"same bytes").decode("ascii") payload: dict[str, Any] = { "user_id": "u_123", "session_id": "chat:c_retry", "messages": [ { "sender_id": "u_123", "role": "user", "timestamp": 1234567890123, "content": [ { "type": "image", "base64": encoded, "name": "same.png", "ext": "png", } ], } ], } async with app_client(config, backend) as client: user_key = await create_user(client) payload["user_key"] = user_key first = await client.post("/memories/add", json=payload) second = await client.post("/memories/add", json=payload) assert first.status_code == 200, first.text assert second.status_code == 200, second.text attachments = repo.list_attachments_for_session("u_123", "chat:c_retry") assert len(attachments) == 1 @pytest.mark.asyncio async def test_flush_memory_forwards_request_to_backend( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/flush", json={ "user_id": "u_123", "user_key": user_key, "session_id": "chat:c_multimodal", "app_id": "default", "project_id": "default", }, ) assert response.status_code == 200, response.text assert response.json() == { "session_id": "chat:c_multimodal", "backend": {"request_id": "flush", "data": {"status": "extracted"}}, } assert backend.flush_calls == [ { "session_id": "chat:c_multimodal", "app_id": "default", "project_id": "default", } ] @pytest.mark.asyncio async def test_search_forwards_default_upstream_options( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["all_user_memory"], }, ) assert response.status_code == 200, response.text assert backend.search_calls == [ { "user_id": "u_123", "query": "hello", "method": "hybrid", "top_k": 8, "include_profile": True, "enable_llm_rerank": True, "app_id": "default", "project_id": "default", } ] @pytest.mark.asyncio async def test_search_forwards_all_upstream_options( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "agent_id": "agent_456", "query": "hello", "scope": ["all_user_memory"], "method": "keyword", "top_k": -1, "radius": 0.4, "include_profile": False, "enable_llm_rerank": False, "app_id": "app_1", "project_id": "project_1", }, ) assert response.status_code == 200, response.text assert backend.search_calls == [ { "agent_id": "agent_456", "query": "hello", "method": "keyword", "top_k": -1, "radius": 0.4, "include_profile": False, "enable_llm_rerank": False, "app_id": "app_1", "project_id": "project_1", } ] @pytest.mark.asyncio @pytest.mark.parametrize( ("field", "value"), [ ("method", "invalid"), ("radius", 1.1), ("top_k", 0), ], ) async def test_search_rejects_invalid_upstream_options( config: GatewayConfig, field: str, value: Any, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) payload = { "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["all_user_memory"], field: value, } response = await client.post("/memories/search", json=payload) assert response.status_code == 422, response.text assert backend.search_calls == [] @pytest.mark.asyncio async def test_search_combines_custom_and_scope_filters( config: GatewayConfig, repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="u_123") backend = FakeBackendClient() custom_filters = {"OR": [{"type": "Conversation"}, {"sender_ids": "u_123"}]} async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["resources"], "filters": custom_filters, }, ) assert response.status_code == 200, response.text assert backend.search_calls[0]["filters"] == { "AND": [ custom_filters, {"session_id": {"in": ["resource:u_123:r_1"]}}, ] } @pytest.mark.asyncio async def test_search_labels_all_memory_types( config: GatewayConfig, ) -> None: backend = FakeBackendClient( search_data={ "episodes": [{"id": "ep_1", "session_id": "chat:c_1", "episode": "e"}], "profiles": [{"id": "profile_1", "profile_data": {"name": "Tom"}}], "agent_cases": [ {"id": "case_1", "session_id": "chat:c_1", "task_intent": "case"} ], "agent_skills": [{"id": "skill_1", "content": "skill"}], "unprocessed_messages": [ {"id": "message_1", "session_id": "chat:c_1", "content": "pending"} ], } ) async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["all_user_memory"], }, ) assert response.status_code == 200, response.text assert [item["memory_type"] for item in response.json()["results"]] == [ "episode", "profile", "agent_case", "agent_skill", "unprocessed_message", ] @pytest.mark.asyncio async def test_search_returns_attachment_when_raw_contains_filename( config: GatewayConfig, repo: MemoryRepository, ) -> None: repo.create_attachment( user_id="u_123", app_id="default", project_id="default", session_id="chat:c_1", resource_id=None, content_type="image", name="Picture.PNG", internal_uri="file:///private/Picture.PNG", source="memory_add_uri", sha256=None, ) backend = FakeBackendClient( [{"id": "ep_1", "session_id": "chat:c_1", "episode": "Saw picture.png"}] ) async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "picture", "scope": ["all_user_memory"], }, ) assert response.status_code == 200, response.text assert response.json()["results"][0]["attachments"] == [ { "type": "image", "name": "Picture.PNG", "internal_uri": "file:///private/Picture.PNG", } ] @pytest.mark.asyncio async def test_search_omits_unmentioned_and_base64_only_attachments( config: GatewayConfig, repo: MemoryRepository, ) -> None: repo.create_attachment( user_id="u_123", app_id="default", project_id="default", session_id="chat:c_1", resource_id=None, content_type="audio", name="tone.wav", internal_uri="file:///private/tone.wav", source="memory_add_base64", sha256=None, ) backend = FakeBackendClient( search_data={ "unprocessed_messages": [ { "id": "message_1", "session_id": "chat:c_1", "content": [{"base64": "encoded-prefix-tone.wav"}], } ] } ) async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "audio", "scope": ["all_user_memory"], }, ) assert response.status_code == 200, response.text assert response.json()["results"][0]["attachments"] == [] @pytest.mark.asyncio async def test_search_attachment_mapping_is_user_isolated( config: GatewayConfig, repo: MemoryRepository, ) -> None: for user_id, name in (("alice", "alice.png"), ("bob", "bob.png")): repo.create_attachment( user_id=user_id, app_id="default", project_id="default", session_id="chat:shared", resource_id=None, content_type="image", name=name, internal_uri=f"file:///private/{name}", source="memory_add_uri", sha256=None, ) backend = FakeBackendClient( [ { "id": "ep_1", "session_id": "chat:shared", "episode": "alice.png and bob.png", } ] ) async with app_client(config, backend) as client: user_key = await create_user(client, "alice") response = await client.post( "/memories/search", json={ "user_id": "alice", "user_key": user_key, "query": "images", "scope": ["all_user_memory"], }, ) assert response.status_code == 200, response.text assert response.json()["results"][0]["attachments"] == [ { "type": "image", "name": "alice.png", "internal_uri": "file:///private/alice.png", } ] @pytest.mark.asyncio async def test_deleted_resource_is_excluded_from_resource_scope_search( config: GatewayConfig, ) -> None: backend = FakeBackendClient( [{"id": "mem_1", "session_id": "resource:u_123:r_live", "episode": "live"}] ) async with app_client(config, backend) as client: user_key = await create_user(client) created = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("note.txt", b"hello", "text/plain")}, ) resource_id = created.json()["resource_id"] delete_response = await client.delete( f"/resources/{resource_id}", params={"user_id": "u_123", "user_key": user_key}, ) search_response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["resources"], "top_k": 8, }, ) assert delete_response.status_code == 200 assert search_response.status_code == 200 assert backend.search_calls == [] assert search_response.json()["results"] == [] repo = MemoryRepository(config.database_path) deleted = repo.get_resource(resource_id) assert deleted is not None assert not Path(deleted["uri"].removeprefix("file://")).exists() @pytest.mark.asyncio async def test_tombstone_filters_search_results( config: GatewayConfig, repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="u_123") repo.add_tombstone( user_id="u_123", memory_id="mem_deleted", session_id=None, reason="user deleted", ) backend = FakeBackendClient( [ {"id": "mem_deleted", "session_id": "resource:u_123:r_1", "episode": "x"}, {"id": "mem_live", "session_id": "resource:u_123:r_1", "episode": "y"}, ] ) async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["resources"], }, ) assert response.status_code == 200, response.text results = response.json()["results"] assert [result["id"] for result in results] == ["mem_live"] @pytest.mark.asyncio async def test_override_replaces_search_result_text( config: GatewayConfig, repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="u_123") backend = FakeBackendClient( [{"id": "mem_1", "session_id": "resource:u_123:r_1", "episode": "old text"}] ) async with app_client(config, backend) as client: user_key = await create_user(client) patch_response = await client.patch( "/memories/mem_1", json={ "user_id": "u_123", "user_key": user_key, "session_id": "resource:u_123:r_1", "override_text": "corrected text", }, ) search_response = await client.post( "/memories/search", json={ "user_id": "u_123", "user_key": user_key, "query": "hello", "scope": ["resources"], }, ) assert patch_response.status_code == 200, patch_response.text result = search_response.json()["results"][0] assert result["id"] == "mem_1" assert result["text"] == "corrected text" assert result["raw"]["episode"] == "old text" @pytest.mark.asyncio async def test_memory_override_rejects_session_owned_by_another_user( config: GatewayConfig, repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_bob", user_id="bob") backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "alice") response = await client.patch( "/memories/mem_1", json={ "user_id": "alice", "user_key": user_key, "session_id": "resource:bob:r_bob", "override_text": "nope", }, ) assert response.status_code == 403, response.text assert repo.get_active_overrides("alice") == [] @pytest.mark.asyncio async def test_memory_delete_requires_owned_session( config: GatewayConfig, repo: MemoryRepository, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client, "u_123") response = await client.request( "DELETE", "/memories/mem_1", json={ "user_id": "u_123", "user_key": user_key, "session_id": "resource:u_123:r_missing", "reason": "manual delete", }, ) assert response.status_code == 403, response.text assert repo.get_tombstones("u_123") == [] @pytest.mark.asyncio async def test_list_resources_returns_only_not_deleted( config: GatewayConfig, ) -> None: backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) first = await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("a.txt", b"a", "text/plain")}, ) await client.post( "/resources", data={"user_id": "u_123", "user_key": user_key}, files={"file": ("b.txt", b"b", "text/plain")}, ) await client.delete( f"/resources/{first.json()['resource_id']}", params={"user_id": "u_123", "user_key": user_key}, ) response = await client.get( "/resources", params={"user_id": "u_123", "user_key": user_key}, ) assert response.status_code == 200 items = response.json()["resources"] assert len(items) == 1 assert items[0]["filename"] == "b.txt" assert items[0]["uri"].startswith("resource://u_123/") @pytest.mark.asyncio async def test_delete_memory_writes_tombstone( config: GatewayConfig, repo: MemoryRepository, ) -> None: create_test_resource(repo, resource_id="r_1", user_id="u_123") backend = FakeBackendClient() async with app_client(config, backend) as client: user_key = await create_user(client) response = await client.request( "DELETE", "/memories/mem_1", json={ "user_id": "u_123", "user_key": user_key, "session_id": "resource:u_123:r_1", "reason": "manual delete", }, ) assert response.status_code == 200, response.text tombstones = repo.get_tombstones("u_123") assert len(tombstones) == 1 assert tombstones[0]["memory_id"] == "mem_1" assert tombstones[0]["session_id"] == "resource:u_123:r_1"