Files
simbarag/tests/unit/test_history_management.py
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

261 lines
10 KiB
Python

"""Real SQLite tests: ownership, joined pagination, exports and malformed requests."""
import base64
import importlib.util
import json
from pathlib import Path
import zlib
import pytest
from quart import Quart
from quart_jwt_extended import JWTManager, create_access_token
from tortoise import Tortoise
from blueprints.conversation.history import history_blueprint, list_owned_conversations
from blueprints.conversation.models import (
Conversation,
ConversationMessage,
Speaker,
UserMemory,
)
from blueprints.users.models import User
@pytest.fixture
async def history_app():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": ["blueprints.users.models", "blueprints.conversation.models"]
},
)
await Tortoise.generate_schemas()
app = Quart(__name__)
app.config.update(
TESTING=True,
JWT_SECRET_KEY="history-test-secret-at-least-thirty-two-characters",
)
JWTManager(app)
app.register_blueprint(history_blueprint)
owner = await User.create(username="owner", email="owner@example.com")
foreign = await User.create(username="foreign", email="foreign@example.com")
async with app.app_context():
headers = {
"Authorization": f"Bearer {create_access_token(identity=str(owner.id))}"
}
try:
yield app.test_client(), headers, owner, foreign
finally:
await Tortoise.close_connections()
async def message(conversation, text, speaker=Speaker.USER, **kwargs):
return await ConversationMessage.create(
conversation=conversation, text=text, speaker=speaker, **kwargs
)
@pytest.mark.asyncio
async def test_search_unique_pagination_and_owner_isolation(history_app):
client, headers, owner, foreign = history_app
a = await Conversation.create(user=owner, name="ordinary")
b = await Conversation.create(user=owner, name="needle title", pinned=True)
secret = await Conversation.create(user=foreign, name="needle foreign", pinned=True)
await Conversation.create(name="needle unowned")
for _ in range(5):
await message(a, "many needle message matches")
await message(secret, "needle secret")
first = await list_owned_conversations(owner.id, "needle", 1, 0)
second = await list_owned_conversations(owner.id, "needle", 1, 1)
assert [first[0]["id"], second[0]["id"]] == [str(b.id), str(a.id)]
assert await list_owned_conversations(owner.id, "needle", 1, 2) == []
assert "needle" in second[0]["snippet"]
assert second[0]["match_message_id"]
response = await client.get(
"/api/history/conversations?search=NEEDLE&limit=10", headers=headers
)
assert {row["id"] for row in await response.get_json()} == {str(a.id), str(b.id)}
@pytest.mark.asyncio
async def test_pin_owner_only_boolean_and_recency(history_app):
client, headers, owner, foreign = history_app
own = await Conversation.create(user=owner, name="own")
other = await Conversation.create(user=foreign, name="other")
for identifier in (other.id, "malformed"):
response = await client.patch(
f"/api/history/conversations/{identifier}/pin",
headers=headers,
json={"pinned": True},
)
assert response.status_code == 404
for body in ({}, {"pinned": "false"}, {"pinned": 1}, [], None):
response = await client.patch(
f"/api/history/conversations/{own.id}/pin", headers=headers, json=body
)
assert response.status_code == 400
original = own.updated_at
for pinned in (True, False):
response = await client.patch(
f"/api/history/conversations/{own.id}/pin",
headers=headers,
json={"pinned": pinned},
)
assert response.status_code == 200
await own.refresh_from_db()
assert own.pinned is pinned
assert own.updated_at == original
await other.refresh_from_db()
assert not other.pinned
@pytest.mark.asyncio
async def test_export_display_and_attachment_provenance(history_app):
client, headers, owner, foreign = history_app
own = await Conversation.create(user=owner, name='quoted "title"')
other = await Conversation.create(user=foreign, name="FOREIGN SECRET")
await message(other, "FOREIGN CONTENT")
await message(own, "hello\n\n📎 notes.md", image_key="foreign/private/secret.png")
await message(own, "[web_search result]\nHIDDEN RAW TOOL CONTEXT", Speaker.TOOL)
await message(own, "answer", Speaker.SIMBA)
await message(own, "malformed tool payload\nHIDDEN MORE", Speaker.TOOL)
response = await client.get(
f"/api/history/conversations/{own.id}/export", headers=headers
)
assert response.status_code == 200
data = await response.get_json()
text = json.dumps(data)
for secret in ("HIDDEN RAW", "HIDDEN MORE", "FOREIGN", "foreign/private"):
assert secret not in text
assert data["messages"][1]["text"] == "[web_search result]"
assert data["messages"][1]["provenance"] == "displayed_tool_header"
assert data["messages"][0]["attachments"] == [
{"kind": "file", "recorded_name": "notes.md", "contents_included": False},
{"kind": "image", "stored_reference_present": True, "contents_included": False},
]
assert "attachment; filename=" in response.headers["Content-Disposition"]
assert response.headers["Cache-Control"] == "no-store"
md = await client.get(
f"/api/history/conversations/{own.id}/export?format=markdown", headers=headers
)
markdown = await md.get_data(as_text=True)
assert md.status_code == 200 and "text/markdown" in md.content_type
assert "📎 notes.md" in markdown and "Raw tool context" in markdown
assert "HIDDEN" not in markdown
for identifier in (other.id, "bad-uuid"):
assert (
await client.get(
f"/api/history/conversations/{identifier}/export", headers=headers
)
).status_code == 404
assert (
await client.get(
f"/api/history/conversations/{own.id}/export?format=html", headers=headers
)
).status_code == 400
@pytest.mark.asyncio
async def test_memory_owner_only_correction_forget_and_validation(history_app):
client, headers, owner, foreign = history_app
own = await UserMemory.create(user=owner, content="old fact")
other = await UserMemory.create(user=foreign, content="FOREIGN MEMORY")
rows = await (await client.get("/api/history/memories", headers=headers)).get_json()
assert [row["id"] for row in rows] == [str(own.id)]
for identifier in (other.id, "invalid"):
for method in (client.patch, client.delete):
assert (
await method(
f"/api/history/memories/{identifier}",
headers=headers,
json={"content": "stolen"},
)
).status_code == 404
for body in (
{},
[],
{"content": None},
{"content": 3},
{"content": " "},
{"content": "x" * 10001},
):
assert (
await client.patch(
f"/api/history/memories/{own.id}", headers=headers, json=body
)
).status_code == 400
response = await client.patch(
f"/api/history/memories/{own.id}",
headers=headers,
json={"content": " corrected fact "},
)
assert response.status_code == 200
assert (await response.get_json())["content"] == "corrected fact"
await own.refresh_from_db()
assert own.content == "corrected fact"
assert (
await client.delete(f"/api/history/memories/{own.id}", headers=headers)
).status_code == 204
assert await UserMemory.get_or_none(id=own.id) is None
await other.refresh_from_db()
assert other.content == "FOREIGN MEMORY"
@pytest.mark.asyncio
async def test_memory_pagination(history_app):
client, headers, owner, _ = history_app
for i in range(5):
await UserMemory.create(user=owner, content=f"fact {i}")
first = await (
await client.get("/api/history/memories?limit=2&offset=0", headers=headers)
).get_json()
second = await (
await client.get("/api/history/memories?limit=2&offset=2", headers=headers)
).get_json()
assert len(first) == len(second) == 2
assert {r["id"] for r in first}.isdisjoint(r["id"] for r in second)
@pytest.mark.asyncio
async def test_authentication_and_malformed_pagination(history_app):
client, headers, owner, _ = history_app
own = await Conversation.create(user=owner, name="own")
memory = await UserMemory.create(user=owner, content="fact")
for method, path in (
(client.get, "/conversations"),
(client.get, "/memories"),
(client.get, f"/conversations/{own.id}/export"),
(client.patch, f"/conversations/{own.id}/pin"),
(client.patch, f"/memories/{memory.id}"),
(client.delete, f"/memories/{memory.id}"),
):
assert (await method("/api/history" + path)).status_code == 401
for path in ("conversations", "memories"):
for query in ("limit=0", "limit=101", "offset=-1", "limit=oops", "offset=1.5"):
assert (
await client.get(f"/api/history/{path}?{query}", headers=headers)
).status_code == 400
assert (
await client.get(
"/api/history/conversations?search=" + "x" * 501, headers=headers
)
).status_code == 400
@pytest.mark.asyncio
async def test_migration_adds_pinned_and_records_state(history_app):
migration_path = (
Path(__file__).parents[2]
/ "migrations/models/20_20261012231017_application_foundation.py"
)
spec = importlib.util.spec_from_file_location("history_migration", migration_path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
sql = await migration.upgrade(None)
assert 'ALTER TABLE "conversations" ADD "pinned" BOOL NOT NULL DEFAULT False' in sql
state = json.loads(zlib.decompress(base64.b64decode(migration.MODELS_STATE)))
assert any(
field["name"] == "pinned" and field["default"] is False
for field in state["models.Conversation"]["data_fields"]
)