"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
This test verifies that:
1. User can provision background sync access via app password
2. Content created via MCP tools is indexed by vector sync
3. Semantic search via Astrolabe UI returns results
4. Plotly 3D visualization container renders correctly
Requires:
- docker-compose up -d app db mcp-multi-user-basic
- ENABLE_SEMANTIC_SEARCH=true on the mcp-multi-user-basic container
"""
import base64
import json
import logging
import re
import uuid
import anyio
import pytest
from playwright.async_api import Page
# Import helper functions from existing test
from tests.conftest import create_mcp_client_session
from tests.integration._search_helpers import document_is_searchable
from tests.integration.test_astrolabe_multi_user_background_sync import (
complete_astrolabe_authorization,
login_to_nextcloud,
)
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
async def wait_for_vector_sync(
mcp_client,
initial_indexed_count: int,
timeout_seconds: int = 60,
*,
search_term: str | None = None,
note_id: int | None = None,
) -> tuple[bool, dict | None]:
"""Wait for vector sync to index newly-created content.
Completion signal:
- When ``search_term`` is provided (preferred), poll ``nc_semantic_search``
until the new document is actually retrievable. This is robust against
full-corpus re-scan churn and doubles as a real end-to-end check — it is
exactly what callers assert downstream.
- Otherwise, fall back to the legacy gauge-delta predicate.
Why the gauge delta is unreliable: under ``VECTOR_SYNC_SCAN_INTERVAL`` the
background sync re-queues the whole corpus every scan, so the corpus-wide
``indexed_count`` is *non-monotonic* — it can be re-counted downward
mid-scan. ``indexed_count > initial_indexed_count`` can therefore never hold
even though the new document is indexed and the status has settled to
``idle`` / ``pending_count == 0``. That false failure was the dominant
multi-user-basic CI flake (``test_astrolabe_plotly_visualization`` /
``test_astrolabe_chunk_context``).
Args:
mcp_client: MCP client session
initial_indexed_count: Indexed document count before creating content
(only used by the legacy gauge-delta fallback)
timeout_seconds: Maximum time to wait for sync
search_term: Unique term contained in the new document; enables the
robust searchability-based completion signal
note_id: ID of the new document, used to match search results exactly
Returns:
Tuple of (success, status_data)
"""
wait_interval = 2
waited = 0
status_data = None
while waited < timeout_seconds:
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
if sync_status.isError:
logger.warning("Vector sync status error: %s", sync_status)
return False, None
status_data = json.loads(sync_status.content[0].text)
indexed_count = status_data.get("indexed_count", 0)
pending_count = status_data.get("pending_count", 1)
logger.info(
"Sync status at %ss: indexed=%s, pending=%s, status=%s",
waited,
indexed_count,
pending_count,
status_data.get("status"),
)
if search_term is not None:
if await document_is_searchable(mcp_client, search_term, note_id):
logger.info(
"✓ Sync complete: document %s retrievable via semantic search",
note_id,
)
return True, status_data
elif indexed_count > initial_indexed_count and pending_count == 0:
logger.info(
"✓ Sync complete: %s documents indexed (was %s)",
indexed_count,
initial_indexed_count,
)
return True, status_data
await anyio.sleep(wait_interval)
waited += wait_interval
return False, status_data
async def navigate_to_astrolabe_main(page: Page):
"""Navigate to Astrolabe main app page (Semantic Search section).
Args:
page: Playwright page instance (must be authenticated)
"""
nextcloud_url = "http://localhost:8080"
logger.info("Navigating to Astrolabe main app...")
await page.goto(f"{nextcloud_url}/apps/astrolabe", wait_until="networkidle")
# Wait for the app to load
await anyio.sleep(1)
logger.info("✓ Successfully loaded Astrolabe main app")
@pytest.mark.integration
@pytest.mark.multi_user_basic
@pytest.mark.timeout(
300
) # 5 minutes - this test involves app-password provisioning + vector sync
async def test_astrolabe_plotly_visualization_with_basic_auth(
browser,
test_users_setup,
configure_astrolabe_for_mcp_server,
):
"""Test Plotly 3D visualization in Astrolabe with multi-user BasicAuth mode.
This test:
1. Configures Astrolabe for the mcp-multi-user-basic service
2. Provisions background sync access for alice via app password
3. Creates a note with unique searchable content (as alice)
4. Waits for vector sync to index the note
5. Performs semantic search in Astrolabe UI
6. Verifies the Plotly visualization renders and results are displayed
"""
# Phase 1: Configure Astrolabe for mcp-multi-user-basic
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
mcp_server_public_url="http://localhost:8003",
)
username = "alice"
password = test_users_setup[username]["password"]
note_id = None
unique_term = None
# Create MCP client with alice's credentials for the multi-user BasicAuth server
credentials = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
auth_header = f"Basic {credentials}"
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Phase 2: Provision background indexing (app-password opt-in; no OAuth)
await login_to_nextcloud(page, username, password)
auth_result = await complete_astrolabe_authorization(page, username, password)
logger.info("Authorization result: %s", auth_result)
# Create MCP client session as alice - all MCP operations inside this block
async with create_mcp_client_session(
url="http://localhost:8003/mcp",
headers={"Authorization": auth_header},
client_name="Alice BasicAuth MCP",
) as alice_mcp_client:
# Phase 3: Get initial indexed count
initial_sync = await alice_mcp_client.call_tool(
"nc_get_vector_sync_status", {}
)
if initial_sync.isError:
pytest.skip("Vector sync not enabled on mcp-multi-user-basic")
initial_data = json.loads(initial_sync.content[0].text)
initial_count = initial_data.get("indexed_count", 0)
logger.info("Initial indexed count: %s", initial_count)
# Create note with unique searchable term
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
note_response = await alice_mcp_client.call_tool(
"nc_notes_create_note",
{
"title": f"Visualization Test Note {unique_term}",
"content": f"""# Testing Plotly Visualization
This note contains the unique term: {unique_term}
It is used to test the 3D vector space visualization in the Astrolabe app.
The visualization should show this document as a point in PCA-reduced space.
## Key Features
- Semantic search with embeddings
- PCA dimension reduction to 3D
- Interactive Plotly scatter3d plot
""",
"category": "Test",
},
)
if note_response.isError:
pytest.fail(f"Failed to create test note: {note_response}")
note_data = json.loads(note_response.content[0].text)
note_id = note_data.get("id")
logger.info("Created test note ID: %s", note_id)
# Phase 4: Wait for vector indexing
sync_complete, status = await wait_for_vector_sync(
alice_mcp_client,
initial_count,
timeout_seconds=90,
search_term=unique_term,
note_id=note_id,
)
assert sync_complete, (
f"Note {note_id} ({unique_term}) never became searchable "
f"within timeout. Last sync status: {status}"
)
# Phase 5: Navigate to Astrolabe and perform search
await navigate_to_astrolabe_main(page)
# Find the Astrolabe search field. The published app differs across
# the NC matrix: NC31 pulls astrolabe <=0.24 (NcTextField -> ,
# submits on Enter); NC32 pulls astrolabe >=0.25 (NcTextArea ->
#