fleet-memory/hindsight-api/tests/test_http_api_integration.py
Derek Bouius d6b7b9b398
Fix base CI issues and the defaults in .env.example (#24)
* Add the LLM_PROVIDER in example

* fix the assert in testing recall

* trial to fix failing client tests

NotImplementedError: Cannot copy out of meta tensor; no data! Please use torch.nn.Module.to_empty() instead of torch.nn.Module.to() when moving module from meta to a different device.

* lock the sentence transformer packages to align with the breaking changes around lazy tensor loading

* Add the LLM_PROVIDER in example

* fix the assert in testing recall

* trial to fix failing client tests

* pre-cache the model so CI doesn't need workarounds

* remove assert that is a race condition

The test was checking that the bank count increased, but with parallel tests (-n 8), other tests can delete their banks while this test is running, causing a race condition. The important assertion is assert test_bank_id in final_banks - which verifies the bank was actually created.

* add debug to figure out why docker build fails sometimes

* use the CPU only version of pytorch to avoid pulling cuda libraries

* add best match strategy to uv

* change the example openai model
2025-12-11 16:48:12 -05:00

428 lines
14 KiB
Python

"""
Integration test for the complete Hindsight API.
Tests all endpoints by starting a FastAPI server and making HTTP requests.
"""
import pytest
import pytest_asyncio
import httpx
from datetime import datetime
from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
"""Create an async test client for the FastAPI app."""
# Memory is already initialized by the conftest fixture (with migrations)
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def test_bank_id():
"""Provide a unique bank ID for this test run."""
return f"integration_test_{datetime.now().timestamp()}"
@pytest.mark.asyncio
async def test_full_api_workflow(api_client, test_bank_id):
"""
End-to-end test covering all major API endpoints in a realistic workflow.
Workflow:
1. Create bank and set profile
2. Store memories (retain)
3. Recall memories
4. Reflect (generate answer)
5. List banks and memories
6. Get bank profile
7. Get visualization data
8. Track documents
9. Test entity endpoints
10. Test operations endpoints
11. Clean up
"""
# ================================================================
# 1. Bank Management
# ================================================================
# List banks (should be empty initially or have other test banks)
response = await api_client.get("/v1/default/banks")
assert response.status_code == 200
initial_banks_data = response.json()["banks"]
initial_banks = [a["bank_id"] for a in initial_banks_data]
# Get bank profile (creates default if not exists)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
profile = response.json()
assert "disposition" in profile
assert "background" in profile
# Add background
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/background",
json={
"content": "A software engineer passionate about AI and memory systems."
}
)
assert response.status_code == 200
assert "software engineer" in response.json()["background"].lower()
# ================================================================
# 2. Memory Storage
# ================================================================
# Store single memory (using batch endpoint with single item)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{
"content": "Alice is a machine learning researcher at Stanford.",
"context": "conversation about team members"
}
]
}
)
assert response.status_code == 200
put_result = response.json()
assert put_result["success"] is True
assert put_result["items_count"] == 1
# Store batch memories
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{
"content": "Bob leads the infrastructure team and loves Kubernetes.",
"context": "team introduction"
},
{
"content": "Charlie recently joined as a product manager from Google.",
"context": "new hire announcement"
}
]
}
)
assert response.status_code == 200
batch_result = response.json()
assert batch_result["success"] is True
assert batch_result["items_count"] == 2
# ================================================================
# 3. Recall (Search)
# ================================================================
# Recall memories
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "Who works on machine learning?",
"thinking_budget": 50
}
)
assert response.status_code == 200
search_results = response.json()
assert "results" in search_results
assert len(search_results["results"]) > 0
# Verify we found Alice
found_alice = any("Alice" in r["text"] for r in search_results["results"])
assert found_alice, "Should find Alice in search results"
# ================================================================
# 4. Reflect (Reasoning)
# ================================================================
# Generate answer using reflect
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflect",
json={
"query": "What do you know about the team members?",
"thinking_budget": 30,
"context": "This is for a team overview document"
}
)
assert response.status_code == 200
reflect_result = response.json()
assert "text" in reflect_result
assert len(reflect_result["text"]) > 0
assert "based_on" in reflect_result
# Verify the answer mentions team members
answer = reflect_result["text"].lower()
assert "alice" in answer or "bob" in answer or "charlie" in answer
# ================================================================
# 5. Visualization & Statistics
# ================================================================
# Get graph data
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/graph")
assert response.status_code == 200
graph_data = response.json()
assert "nodes" in graph_data
assert "edges" in graph_data
# Get memory statistics
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
assert "total_nodes" in stats
assert stats["total_nodes"] > 0
# List memory units
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"limit": 10}
)
assert response.status_code == 200
memory_units = response.json()
assert "items" in memory_units
assert len(memory_units["items"]) > 0
# ================================================================
# 6. Document Tracking
# ================================================================
# Store memory with document
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{
"content": "Project timeline: MVP launch in Q1, Beta in Q2.",
"context": "product roadmap",
"document_id": "roadmap-2024-q1"
}
]
}
)
assert response.status_code == 200
# List documents
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
documents = response.json()
assert "items" in documents
assert len(documents["items"]) > 0
# Get specific document
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/documents/roadmap-2024-q1"
)
assert response.status_code == 200
doc_info = response.json()
assert "id" in doc_info
assert doc_info["id"] == "roadmap-2024-q1"
assert doc_info["memory_unit_count"] > 0
# Note: Document deletion is tested separately in test_document_deletion
# ================================================================
# 7. Update and Verify Bank Disposition
# ================================================================
# Update disposition traits
response = await api_client.put(
f"/v1/default/banks/{test_bank_id}/profile",
json={
"disposition": {
"skepticism": 4,
"literalism": 3,
"empathy": 4
}
}
)
assert response.status_code == 200
# Check profile again (should have updated disposition)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
updated_profile = response.json()
assert "software engineer" in updated_profile["background"].lower()
# ================================================================
# 8. Test Entity Endpoints
# ================================================================
# List entities
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities")
assert response.status_code == 200
entities_data = response.json()
assert "items" in entities_data
# Get specific entity if any exist
if len(entities_data['items']) > 0:
entity_id = entities_data['items'][0]['id']
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/entities/{entity_id}"
)
assert response.status_code == 200
entity_detail = response.json()
assert "id" in entity_detail
# Test regenerate observations
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/entities/{entity_id}/regenerate"
)
assert response.status_code == 200
# ================================================================
# 9. List All Banks (should include our test bank)
# ================================================================
response = await api_client.get("/v1/default/banks")
assert response.status_code == 200
final_banks_data = response.json()["banks"]
final_banks = [a["bank_id"] for a in final_banks_data]
assert test_bank_id in final_banks
# Don't assert count increases due to parallel test cleanup races
# Just verify our bank exists in the list
# ================================================================
# 10. Clean Up
# ================================================================
# Note: No delete bank endpoint in API, so test data remains in DB
# Using timestamped bank IDs prevents conflicts between test runs
@pytest.mark.asyncio
async def test_error_handling(api_client):
"""Test that API properly handles error cases."""
# Invalid request (missing required field)
response = await api_client.post(
"/v1/default/banks/error_test/memories",
json={
"items": [
{
# Missing "content"
"context": "test"
}
]
}
)
assert response.status_code == 422 # Validation error
# Recall with invalid parameters
response = await api_client.post(
"/v1/default/banks/error_test/memories/recall",
json={
"query": "test",
"budget": "invalid_budget" # Invalid budget value (should be low/mid/high)
}
)
assert response.status_code == 422
# Get non-existent document
response = await api_client.get(
"/v1/default/banks/nonexistent_bank/documents/fake-doc-id"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_concurrent_requests(api_client):
"""Test that API can handle concurrent requests."""
bank_id = f"concurrent_test_{datetime.now().timestamp()}"
# Store multiple memories concurrently (simulated with sequential calls)
responses = []
test_facts = [
"David works as a data scientist at Microsoft.",
"Emily is the CEO of a startup in San Francisco.",
"Frank teaches computer science at MIT.",
"Grace is a software architect specializing in distributed systems.",
"Henry leads the product team at Amazon."
]
for fact in test_facts:
response = await api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={
"items": [
{
"content": fact,
"context": "concurrent test"
}
]
}
)
responses.append(response)
# All should succeed
assert all(r.status_code == 200 for r in responses)
assert all(r.json()["success"] for r in responses)
# Verify all facts stored
response = await api_client.get(
f"/v1/default/banks/{bank_id}/memories/list",
params={"limit": 20}
)
assert response.status_code == 200
items = response.json()["items"]
assert len(items) >= 5
@pytest.mark.asyncio
async def test_document_deletion(api_client):
"""Test document deletion including cascade deletion of memory units and links."""
test_bank_id = f"doc_delete_test_{datetime.now().timestamp()}"
# Store a document with memory
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{
"content": "The quarterly sales report shows a 25% increase in revenue.",
"context": "Q1 financial review",
"document_id": "sales-report-q1-2024"
}
]
}
)
assert response.status_code == 200
# Verify document exists
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 200
doc_info = response.json()
initial_units = doc_info["memory_unit_count"]
assert initial_units > 0
# Delete the document
response = await api_client.delete(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 200
delete_result = response.json()
assert delete_result["success"] is True
assert delete_result["document_id"] == "sales-report-q1-2024"
assert delete_result["memory_units_deleted"] == initial_units
# Verify document is gone (should return 404)
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404
# Verify document is not in the list
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
documents = response.json()
doc_ids = [doc["id"] for doc in documents["items"]]
assert "sales-report-q1-2024" not in doc_ids
# Try to delete again (should return 404)
response = await api_client.delete(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404