|
1 | 1 | """Tests for v2 memory router endpoints.""" |
2 | 2 |
|
3 | | -import pytest |
4 | | -from httpx import AsyncClient |
| 3 | +import importlib |
| 4 | +from contextlib import contextmanager |
5 | 5 | from pathlib import Path |
| 6 | +from types import SimpleNamespace |
| 7 | +from typing import Any |
| 8 | + |
| 9 | +import pytest |
| 10 | +from fastapi import FastAPI |
| 11 | +from httpx import ASGITransport, AsyncClient |
6 | 12 |
|
7 | 13 | from basic_memory import db |
| 14 | +from basic_memory.deps.services import get_context_service_v2_external |
8 | 15 | from basic_memory.models import Project |
9 | 16 | from basic_memory.schemas.memory import ( |
10 | 17 | MAX_CONTEXT_DEPTH, |
11 | 18 | MAX_CONTEXT_PAGE_SIZE, |
12 | 19 | MAX_CONTEXT_RELATED_RESULTS, |
13 | 20 | MAX_RECENT_ACTIVITY_PAGE_SIZE, |
14 | 21 | ) |
| 22 | +from basic_memory.services.exceptions import AmbiguousIdentifierError |
| 23 | + |
| 24 | +memory_router_module = importlib.import_module("basic_memory.api.v2.routers.memory_router") |
| 25 | + |
| 26 | + |
| 27 | +class _RecordingSpan: |
| 28 | + """Capture the attributes the memory router ships on its root span.""" |
| 29 | + |
| 30 | + def __init__(self, name: str, attributes: dict[str, Any]) -> None: |
| 31 | + self.name = name |
| 32 | + self.attributes = attributes |
| 33 | + |
| 34 | + def set_attribute(self, key: str, value: Any) -> None: |
| 35 | + self.attributes[key] = value |
| 36 | + |
| 37 | + def set_attributes(self, values: dict[str, Any]) -> None: |
| 38 | + self.attributes.update(values) |
| 39 | + |
| 40 | + def __enter__(self) -> "_RecordingSpan": |
| 41 | + return self |
| 42 | + |
| 43 | + def __exit__(self, *exc_info: object) -> None: |
| 44 | + return None |
| 45 | + |
| 46 | + |
| 47 | +@contextmanager |
| 48 | +def _record_memory_router_spans(monkeypatch): |
| 49 | + spans: list[_RecordingSpan] = [] |
| 50 | + |
| 51 | + def span(name: str, **attributes: Any) -> _RecordingSpan: |
| 52 | + recorded = _RecordingSpan(name, dict(attributes)) |
| 53 | + spans.append(recorded) |
| 54 | + return recorded |
| 55 | + |
| 56 | + monkeypatch.setattr(memory_router_module, "logfire", SimpleNamespace(span=span)) |
| 57 | + yield spans |
| 58 | + |
| 59 | + |
| 60 | +def _root_memory_span(spans: list[_RecordingSpan]) -> _RecordingSpan: |
| 61 | + return next(span for span in spans if span.name == "api.request.memory.build_context") |
| 62 | + |
| 63 | + |
| 64 | +class _TraversalSpy: |
| 65 | + def __init__(self) -> None: |
| 66 | + self.calls = 0 |
| 67 | + |
| 68 | + async def build_context(self, *args: Any, **kwargs: Any) -> None: |
| 69 | + self.calls += 1 |
| 70 | + raise AssertionError("context traversal must not run for a rejected locator") |
| 71 | + |
| 72 | + |
| 73 | +class _ExplodingContextService: |
| 74 | + async def build_context(self, *args: Any, **kwargs: Any) -> None: |
| 75 | + raise RuntimeError("context backend exploded") |
| 76 | + |
| 77 | + |
| 78 | +class _AmbiguousContextService: |
| 79 | + async def build_context(self, *args: Any, **kwargs: Any) -> None: |
| 80 | + raise AmbiguousIdentifierError( |
| 81 | + "Bean Origins", |
| 82 | + [ |
| 83 | + ("bean-origins", "Bean Origins.md"), |
| 84 | + ("archive/bean-origins", "archive/Bean Origins.md"), |
| 85 | + ], |
| 86 | + ) |
15 | 87 |
|
16 | 88 |
|
17 | 89 | async def create_test_entity( |
@@ -449,3 +521,126 @@ async def test_get_memory_context_skips_observations_when_asked( |
449 | 521 | == full_data["results"][0]["primary_result"]["permalink"] |
450 | 522 | ) |
451 | 523 | assert shape_data["results"][0]["related_results"] == full_data["results"][0]["related_results"] |
| 524 | + |
| 525 | + |
| 526 | +@pytest.mark.asyncio |
| 527 | +@pytest.mark.parametrize( |
| 528 | + ("locator", "expected_detail"), |
| 529 | + [ |
| 530 | + # The production locator shape: an arbitrary HTTPS URL embedded in a path. |
| 531 | + ("workspace/project/https://example.invalid/notes", "contains protocol scheme"), |
| 532 | + ("", "cannot be empty"), |
| 533 | + ("%20%20%20", "cannot be empty or whitespace"), |
| 534 | + ("notes%3Cbrackets%3E", "contains invalid characters"), |
| 535 | + ("notes//meeting", "contains double slashes"), |
| 536 | + ], |
| 537 | +) |
| 538 | +async def test_get_memory_context_rejects_malformed_locators( |
| 539 | + app: FastAPI, |
| 540 | + client: AsyncClient, |
| 541 | + v2_project_url: str, |
| 542 | + locator: str, |
| 543 | + expected_detail: str, |
| 544 | +): |
| 545 | + """Malformed locators are caller input, not an internal server error (#1879).""" |
| 546 | + traversal = _TraversalSpy() |
| 547 | + app.dependency_overrides[get_context_service_v2_external] = lambda: traversal |
| 548 | + try: |
| 549 | + response = await client.get(f"{v2_project_url}/memory/{locator}") |
| 550 | + finally: |
| 551 | + app.dependency_overrides.pop(get_context_service_v2_external, None) |
| 552 | + |
| 553 | + assert response.status_code == 400 |
| 554 | + assert expected_detail in response.json()["detail"] |
| 555 | + # The boundary rejects before the context service is touched. |
| 556 | + assert traversal.calls == 0 |
| 557 | + |
| 558 | + |
| 559 | +@pytest.mark.asyncio |
| 560 | +async def test_get_memory_context_accepts_relative_and_memory_url_locators( |
| 561 | + client: AsyncClient, |
| 562 | + test_graph, |
| 563 | + v2_project_url: str, |
| 564 | +): |
| 565 | + """Both accepted locator spellings still resolve to the same context.""" |
| 566 | + relative = await client.get(f"{v2_project_url}/memory/test/root") |
| 567 | + scheme = await client.get(f"{v2_project_url}/memory/memory://test/root") |
| 568 | + |
| 569 | + assert relative.status_code == 200 |
| 570 | + assert scheme.status_code == 200 |
| 571 | + assert ( |
| 572 | + relative.json()["results"][0]["primary_result"]["permalink"] |
| 573 | + == scheme.json()["results"][0]["primary_result"]["permalink"] |
| 574 | + ) |
| 575 | + |
| 576 | + |
| 577 | +@pytest.mark.asyncio |
| 578 | +async def test_get_memory_context_records_input_rejected_outcome( |
| 579 | + client: AsyncClient, |
| 580 | + v2_project_url: str, |
| 581 | + monkeypatch, |
| 582 | +): |
| 583 | + """The root span records a bounded terminal outcome for caller input rejection.""" |
| 584 | + with _record_memory_router_spans(monkeypatch) as spans: |
| 585 | + response = await client.get( |
| 586 | + f"{v2_project_url}/memory/workspace/project/https://example.invalid/notes" |
| 587 | + ) |
| 588 | + |
| 589 | + assert response.status_code == 400 |
| 590 | + root_span = _root_memory_span(spans) |
| 591 | + assert root_span.attributes["outcome"] == "input_rejected" |
| 592 | + # The span carries a bounded outcome, not the caller's uploaded locator content. |
| 593 | + assert "example.invalid" not in repr(root_span.attributes) |
| 594 | + |
| 595 | + |
| 596 | +@pytest.mark.asyncio |
| 597 | +async def test_get_memory_context_records_success_outcome( |
| 598 | + client: AsyncClient, |
| 599 | + test_graph, |
| 600 | + v2_project_url: str, |
| 601 | + monkeypatch, |
| 602 | +): |
| 603 | + with _record_memory_router_spans(monkeypatch) as spans: |
| 604 | + response = await client.get(f"{v2_project_url}/memory/test/root") |
| 605 | + |
| 606 | + assert response.status_code == 200 |
| 607 | + assert _root_memory_span(spans).attributes["outcome"] == "success" |
| 608 | + |
| 609 | + |
| 610 | +@pytest.mark.asyncio |
| 611 | +async def test_get_memory_context_keeps_service_failures_as_server_errors( |
| 612 | + app: FastAPI, |
| 613 | + v2_project_url: str, |
| 614 | + monkeypatch, |
| 615 | +): |
| 616 | + """A genuine context-service failure stays visible as a 500 and a failed outcome.""" |
| 617 | + app.dependency_overrides[get_context_service_v2_external] = lambda: _ExplodingContextService() |
| 618 | + try: |
| 619 | + with _record_memory_router_spans(monkeypatch) as spans: |
| 620 | + async with AsyncClient( |
| 621 | + transport=ASGITransport(app=app, raise_app_exceptions=False), |
| 622 | + base_url="http://test", |
| 623 | + ) as failing_client: |
| 624 | + response = await failing_client.get(f"{v2_project_url}/memory/test/root") |
| 625 | + finally: |
| 626 | + app.dependency_overrides.pop(get_context_service_v2_external, None) |
| 627 | + |
| 628 | + assert response.status_code == 500 |
| 629 | + assert _root_memory_span(spans).attributes["outcome"] == "failed" |
| 630 | + |
| 631 | + |
| 632 | +@pytest.mark.asyncio |
| 633 | +async def test_get_memory_context_preserves_ambiguous_identifier_conflict( |
| 634 | + app: FastAPI, |
| 635 | + client: AsyncClient, |
| 636 | + v2_project_url: str, |
| 637 | +): |
| 638 | + """Ambiguous identifiers keep their existing 409 conflict response.""" |
| 639 | + app.dependency_overrides[get_context_service_v2_external] = lambda: _AmbiguousContextService() |
| 640 | + try: |
| 641 | + response = await client.get(f"{v2_project_url}/memory/Bean%20Origins") |
| 642 | + finally: |
| 643 | + app.dependency_overrides.pop(get_context_service_v2_external, None) |
| 644 | + |
| 645 | + assert response.status_code == 409 |
| 646 | + assert "Ambiguous identifier 'Bean Origins'" in response.json()["detail"] |
0 commit comments