Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_bank_stats.py
T
Nicolò Boschi 66992496f5 fix(api): 404 bank-scoped reads for a bank that does not exist (#4175) (#4186)
* fix(api): 404 bank-scoped reads for a bank that does not exist (#4175)

GET /stats and GET /memories/list answered 200 with zeroed counters and an
empty page for a bank nobody ever created — byte-identical to a healthy, empty
bank. A monitor built on either kept passing after the bank it watched was
renamed, deleted or recreated under another id, and a typo in bank_id was never
surfaced.

The same held for every other bank-scoped aggregate/list read: /graph,
/stats/memories-timeseries, /entities, /entities/graph, /mental-models,
/knowledge-base/{tree,export,search}, /directives, /documents, /tags,
/operations, /observations/scopes, /config and /webhooks. (Sub-resource GETs
already 404 on the missing child.)

Each of those engine reads now calls _require_bank_exists after its own
authentication and read authorization, so the check neither widens what a
request may see nor creates the bank; the profile row is cached per process, so
an existing bank costs no extra query.

The 404 is declared in the OpenAPI spec on those operations, so generated
clients have a documented missing-bank case. The Rust client's build script
drops schema-less error responses first: progenitor models at most one error
type per operation and the typed 422 is the one worth keeping.

* fix(api): derive the 404 endpoint list from the routing table, not by hand

Two follow-ups to the same fix.

The regression test enumerated the 17 bank-scoped reads by hand, so a
bank-scoped collection GET added later would be covered the day someone
remembered to extend the file — exactly the sibling-parity trap the reviewed
change is about. It now walks the app's own routes and asserts the contract over
every one of them, with two commented exemptions (/document-transfer and
/profile, both withdrawn endpoints that answer 410 Gone for every bank). The
scan immediately found both, which the hand-written list had missed.

The rebase onto main also landed the /profile removal underneath the earlier
commit, leaving a declared 404 on an endpoint that can now only ever return 410.
Dropped it, and regenerated the spec and clients.

* fix(tests): create the bank in tests that read a bank they never created

CI found three suites that reached an engine read with no bank row, which the
new 404 turns from an empty result into an error. All three are test setup gaps,
not behaviour the fix gets wrong — a real deployment always has the row, because
every write path (retain included) creates it before anything else exists.

- test_memories_extension: a store owns the facts, never the bank row itself, so
  the three seam reads now create the bank the way a retain would.
- test_schema_isolation: the bank row goes into each tenant schema alongside the
  memory_units row it inserts directly — "created" is per schema, which is part
  of what the test is about.
- test_knowledge_search_text_search_disabled: this engine is stubbed down to the
  one method under test and has no real pool, so the existence read is stubbed
  alongside _authenticate_tenant.

Also carries the docs-skill copy of the OpenAPI spec, which verify-generated-files
caught: it mirrors hindsight-docs/static/openapi.json and was left behind when the
/profile 404 was dropped.
2026-09-07 13:32:10 +02:00

457 lines
18 KiB
Python

"""
Tests for the bank stats endpoint and the memories-timeseries endpoint.
Covers the new fields exposed by GET /v1/default/banks/{bank_id}/stats
(operations_by_status) and the new endpoint
GET /v1/default/banks/{bank_id}/stats/memories-timeseries.
"""
import uuid
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from hindsight_api import RequestContext
from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
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_asyncio.fixture
async def test_bank_id(memory):
bank_id = f"stats_test_{datetime.now().timestamp()}"
# Create the bank. Bank-scoped reads 404 for a bank nobody created (#4175),
# so a stats test that only invents an id would exercise that path instead of
# the empty-bank one it is about.
await memory.get_bank_profile(bank_id, request_context=RequestContext())
return bank_id
async def _insert_memory(memory, bank_id: str, text: str, *, failed: bool = False) -> str:
"""Insert a single experience memory, optionally marked as consolidation-failed."""
mem_id = uuid.uuid4()
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, created_at, consolidation_failed_at)
VALUES ($1, $2, $3, 'experience', now(), CASE WHEN $4 THEN now() ELSE NULL END)
""",
mem_id,
bank_id,
text,
failed,
)
return str(mem_id)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_stats_exposes_memory_write_watermark(api_client, memory, test_bank_id):
"""/stats must carry the bank's newest memory write time.
It rides along on the consolidation aggregate for free, and is what the
mental-models card and the knowledge tree compare each `last_refreshed_at`
against — the alternative being a scan of the bank's memories per model.
"""
try:
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
# Nulls are dropped from responses, so an empty bank carries no watermark.
assert response.json().get("last_memory_write_at") is None
mem_id = await _insert_memory(memory, test_bank_id, "A memory lands in the bank.")
# The 60s TTL would otherwise serve the pre-insert (empty) payload.
await memory._bank_stats_cache.clear()
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
async with memory._pool.acquire() as conn:
written_at = await conn.fetchval("SELECT updated_at FROM memory_units WHERE id = $1::uuid", mem_id)
assert datetime.fromisoformat(response.json()["last_memory_write_at"]) == written_at
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_bank_stats_exposes_operations_by_status(api_client, test_bank_id):
"""/stats should return operations_by_status with all finished operations."""
try:
# Kick off a retain so at least one completed operation exists.
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Alice is a software engineer.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
assert "operations_by_status" in stats
assert isinstance(stats["operations_by_status"], dict)
# A synchronous retain finishes as "completed".
assert stats["operations_by_status"].get("completed", 0) >= 1
# pending/failed counters should still be present as scalar mirrors.
assert stats["pending_operations"] == stats["operations_by_status"].get("pending", 0)
assert stats["failed_operations"] == stats["operations_by_status"].get("failed", 0)
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"period,expected_count,expected_trunc",
[
("1h", 60, "minute"),
("12h", 12, "hour"),
("1d", 24, "hour"),
("7d", 7, "day"),
("30d", 30, "day"),
("90d", 90, "day"),
],
)
async def test_memories_timeseries_periods(api_client, test_bank_id, period, expected_count, expected_trunc):
"""Every period must return the full expected bucket count and trunc."""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Bob works on infrastructure.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": period},
)
assert response.status_code == 200
body = response.json()
assert body["bank_id"] == test_bank_id
assert body["period"] == period
assert body["trunc"] == expected_trunc
assert len(body["buckets"]) == expected_count
for bucket in body["buckets"]:
assert "time" in bucket
# Bucket `time` must serialize as a tz-aware ISO (ending in `+00:00` or `Z`).
# A naive ISO (`2026-04-18T00:00:00`) would be parsed as local time by
# `new Date()` per ECMA-262, shifting the chart by the browser's timezone.
assert bucket["time"].endswith("+00:00") or bucket["time"].endswith("Z"), (
f"bucket time must include UTC offset, got {bucket['time']!r}"
)
assert bucket["world"] >= 0
assert bucket["experience"] >= 0
assert bucket["observation"] >= 0
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_invalid_period_falls_back(api_client, test_bank_id):
"""An unknown period must fall back to the 7d default."""
try:
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "nonsense"},
)
assert response.status_code == 200
body = response.json()
assert body["period"] == "7d"
assert body["trunc"] == "day"
assert len(body["buckets"]) == 7
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(api_client, test_bank_id):
"""A bank with no memories must still return the full zero-filled bucket set."""
try:
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "7d"},
)
assert response.status_code == 200
body = response.json()
assert len(body["buckets"]) == 7
for bucket in body["buckets"]:
assert bucket["world"] == 0
assert bucket["experience"] == 0
assert bucket["observation"] == 0
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_memories_timeseries_reflects_retained_memories(api_client, test_bank_id):
"""Freshly-retained memories must show up in today's bucket counts."""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"items": [
{"content": "Alice is a software engineer.", "context": "team"},
{"content": "Bob works on infrastructure.", "context": "team"},
]
},
)
assert response.status_code == 200
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
params={"period": "7d"},
)
assert response.status_code == 200
body = response.json()
totals = sum(b["world"] + b["experience"] + b["observation"] for b in body["buckets"])
assert totals >= 2, "expected at least two memories across all buckets"
# Those memories should land in the most-recent bucket.
latest = body["buckets"][-1]
assert latest["world"] + latest["experience"] + latest["observation"] >= 2
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_stats_reports_failed_consolidation(api_client, memory, test_bank_id):
"""/stats must surface the count of memories with consolidation_failed_at set."""
try:
await _insert_memory(memory, test_bank_id, "Alice failed 1.", failed=True)
await _insert_memory(memory, test_bank_id, "Alice failed 2.", failed=True)
await _insert_memory(memory, test_bank_id, "Alice pending.", failed=False)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
assert stats["failed_consolidation"] == 2
# Pending is the work the consolidator will still do, so the two failed
# memories are counted only as failed — the buckets are disjoint.
assert stats["pending_consolidation"] == 1
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_pending_consolidation_matches_pending_memory_list(api_client, memory, test_bank_id):
"""pending_consolidation must agree with ?consolidation_state=pending.
Both are meant to answer "what is left to consolidate"; when the stats gauge
counted permanently failed memories too, it sat above the list total by
exactly failed_consolidation and never reached zero.
"""
try:
await _insert_memory(memory, test_bank_id, "Still queued 1.", failed=False)
await _insert_memory(memory, test_bank_id, "Still queued 2.", failed=False)
await _insert_memory(memory, test_bank_id, "Given up on.", failed=True)
stats_response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert stats_response.status_code == 200
stats = stats_response.json()
list_response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"consolidation_state": "pending", "limit": 1},
)
assert list_response.status_code == 200
assert stats["pending_consolidation"] == list_response.json()["total"] == 2
assert stats["failed_consolidation"] == 1
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_memories_filter_by_consolidation_state_failed(api_client, memory, test_bank_id):
"""?consolidation_state=failed returns only memories with consolidation_failed_at set."""
try:
failed_id = await _insert_memory(memory, test_bank_id, "Broken item.", failed=True)
await _insert_memory(memory, test_bank_id, "Healthy item.", failed=False)
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"consolidation_state": "failed"},
)
assert response.status_code == 200
body = response.json()
ids = [item["id"] for item in body["items"]]
assert failed_id in ids
assert body["total"] == 1
assert body["items"][0]["consolidation_failed_at"] is not None
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_list_memories_filter_by_consolidation_state_rejects_unknown(api_client, test_bank_id):
"""An invalid consolidation_state value must return a 400 (not 500)."""
try:
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"consolidation_state": "bogus"},
)
assert response.status_code == 400
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_bank_stats_link_counts_have_no_join(api_client, test_bank_id):
"""link_counts must be populated; the deprecated breakdown fields must be empty.
Confirms the simplified single-table aggregation still produces the totals
the UI reads (`links_by_link_type`) without the historical
memory_links⇒memory_units join that powered the 2D `links_breakdown` no
consumer reads.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Carol leads platform engineering.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
# link totals must still come back so the UI overview cards render.
assert isinstance(stats["links_by_link_type"], dict)
assert stats["total_links"] >= 0
# Deprecated breakdown fields stay in the response shape but are empty.
assert stats["links_breakdown"] == {}
assert stats["links_by_fact_type"] == {}
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_get_bank_freshness_returns_only_consolidation_fields(memory, test_bank_id):
"""get_bank_freshness must return just the freshness keys, no link aggregation."""
from hindsight_api.extensions import RequestContext
try:
await _insert_memory(memory, test_bank_id, "Headed for consolidation.", failed=False)
await _insert_memory(memory, test_bank_id, "Also pending.", failed=True)
freshness = await memory.get_bank_freshness(
test_bank_id,
request_context=RequestContext(internal=True),
)
assert set(freshness.keys()) == {
"last_consolidated_at",
"last_memory_write_at",
"pending_consolidation",
"failed_consolidation",
}
# Same disjoint buckets as /stats: the failed memory is not also pending.
assert freshness["pending_consolidation"] == 1
assert freshness["failed_consolidation"] == 1
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_reflect_uses_freshness_not_bank_stats(memory, test_bank_id):
"""reflect() must call the cheap freshness query, not get_bank_stats.
Counts calls to `_compute_bank_stats` (the heavy loader) during a reflect
invocation; it must stay at zero — reflect should route through
`get_bank_freshness` instead.
"""
from hindsight_api.extensions import RequestContext
try:
# Seed a single memory so reflect has something to inspect.
await _insert_memory(memory, test_bank_id, "Reflect seed.", failed=False)
compute_calls = 0
original_compute = memory._compute_bank_stats
async def counting_compute(bank_id: str):
nonlocal compute_calls
compute_calls += 1
return await original_compute(bank_id)
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
await memory._bank_stats_cache.clear()
try:
await memory.reflect(
test_bank_id,
"What do you know about this bank?",
request_context=RequestContext(internal=True),
)
except Exception:
# reflect may fail without a configured LLM in this test env;
# we only care that it did not invoke the heavy stats loader
# before failing.
pass
assert compute_calls == 0
finally:
memory._compute_bank_stats = original_compute # type: ignore[method-assign]
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_bank_stats_served_from_cache_on_repeat_call(api_client, memory, test_bank_id):
"""A second /stats call within the TTL must not re-run the aggregations.
The cache layer wraps the DB-heavy `_compute_bank_stats` body; counting
its invocations is the cleanest way to prove the wiring works without
relying on timing.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Bob is a project manager.", "context": "team"}]},
)
assert response.status_code == 200
original = memory._compute_bank_stats
call_count = 0
async def counting_compute(bank_id: str):
nonlocal call_count
call_count += 1
return await original(bank_id)
# Make sure no stale entry exists from prior test ordering.
await memory._bank_stats_cache.clear()
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
first = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
second = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert first.status_code == 200
assert second.status_code == 200
assert first.json() == second.json()
assert call_count == 1
finally:
memory._compute_bank_stats = original # type: ignore[method-assign]
await memory._bank_stats_cache.clear()
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")