Files
ZNJJ-api-server/test/services/test_delete_failure_semantics.py
T
Eric Wang 97de142e6d feat(storage): add selectable SQLite and PostgreSQL persistence
- Select one storage backend per instance via AGENT_STORAGE, retaining
  memory as the backward-compatible default. Manage the selected async
  repository, official checkpointer and connection pools in lifespan;
  fail startup on unavailable or uninitialized storage without fallback.
- Add SQLite and PostgreSQL adapters, schema initialization CLI, sample
  PostgreSQL compose configuration and a Windows-compatible serve entry.
  SQLite enforces single-instance file ownership; PostgreSQL uses a
  dedicated pool for session advisory locks plus version/epoch fencing.
- Make repository IO and commit callbacks awaitable. Persist business
  fields, a complete committed graph snapshot and turn result/version in
  one business transaction before success. Rebuild execution checkpoints
  from that snapshot, discarding uncommitted progress after interruption.
- Support optional Idempotency-Key on durable chat requests. Replay
  committed results and return 409 on changed request content; delete
  removes business/history data and replay records while retaining only
  an epoch/version tombstone to reject stale commits.
- Document migration, database switching boundaries, backup/deployment
  expectations and crash recovery. Ignore generated database files.

Validation: 315 tests passed with real SQLite and isolated PostgreSQL,
including abrupt child-process exits before/after business commit;
both backends passed CLI migration and actual HTTP server restart smoke.
Existing API tests were rechecked after isolating them from user storage.
An initial Windows CreateProcess permission failure did not recur in the
full diagnostic run; failure assertions were not skipped or relaxed.
2026-10-24 13:41:54 +08:00

100 lines
4.0 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from src.core import fastgpt_client as wiring
from src.services.app_service import AccidentAppService
from src.services.repository import CheckpointNotFoundError, InMemorySessionRepository
@pytest.mark.asyncio
@pytest.mark.parametrize('error', [
ConnectionError('storage offline'), TimeoutError('storage timeout'),
RuntimeError('no such thread'), KeyError('storage schema'),
])
async def test_checkpoint_failure_keeps_business_fields_and_allows_retry(error):
repo = InMemorySessionRepository()
service = AccidentAppService(SimpleNamespace(), repo)
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock(side_effect=error)
with pytest.raises(type(error)):
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '"test-plate"'
assert not service._locks._entries
drop.side_effect = None
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '""'
@pytest.mark.asyncio
async def test_explicit_missing_checkpoint_is_idempotent():
service = AccidentAppService(SimpleNamespace(), InMemorySessionRepository())
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock(side_effect=CheckpointNotFoundError())
await service.delete_session('s', drop)
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '""'
assert drop.await_count == 2
@pytest.mark.asyncio
async def test_cancelled_checkpoint_cleanup_keeps_business_fields():
service = AccidentAppService(SimpleNamespace(), InMemorySessionRepository())
await service.set_info('s', 'hphm1', 'test-plate')
started = asyncio.Event()
async def drop(_):
started.set()
await asyncio.Future()
task = asyncio.create_task(service.delete_session('s', drop))
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert await service.get_info('s', 'hphm1') == '"test-plate"'
assert not service._locks._entries
@pytest.mark.asyncio
async def test_business_delete_failure_is_not_reported_as_success():
class FailingRepository(InMemorySessionRepository):
async def delete(self, _):
raise ConnectionError('business storage offline')
service = AccidentAppService(SimpleNamespace(), FailingRepository())
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock()
with pytest.raises(ConnectionError):
await service.delete_session('s', drop)
drop.assert_awaited_once_with('s')
assert await service.get_info('s', 'hphm1') == '"test-plate"'
@pytest.mark.asyncio
async def test_checkpoint_cleanup_uses_async_api_and_propagates_errors(monkeypatch):
saver = SimpleNamespace(adelete_thread=AsyncMock(side_effect=ConnectionError()), delete_thread=Mock())
monkeypatch.setattr(wiring, '_langgraph_graph', SimpleNamespace(checkpointer=saver))
with pytest.raises(ConnectionError):
await wiring.delete_chat_session('s')
saver.adelete_thread.assert_awaited_once_with('s')
saver.delete_thread.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('graph', [None, SimpleNamespace(), SimpleNamespace(checkpointer=object())])
async def test_missing_cleanup_capability_fails(monkeypatch, graph):
monkeypatch.setattr(wiring, '_langgraph_graph', graph)
with pytest.raises(RuntimeError):
await wiring.delete_chat_session('s')
@pytest.mark.asyncio
async def test_sync_checkpoint_cleanup_is_supported_and_errors_propagate(monkeypatch):
saver = SimpleNamespace(delete_thread=Mock())
monkeypatch.setattr(wiring, '_langgraph_graph', SimpleNamespace(checkpointer=saver))
await wiring.delete_chat_session('s')
saver.delete_thread.assert_called_once_with('s')
saver.delete_thread.side_effect = ConnectionError()
with pytest.raises(ConnectionError):
await wiring.delete_chat_session('s')