173 lines
5.6 KiB
Python
173 lines
5.6 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
from types import SimpleNamespace
|
||
|
|
import unittest
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
from services.openai_responses.protocol import ResponsesAPIError
|
||
|
|
from services.openai_responses.state import (
|
||
|
|
IDEMPOTENCY_CONSTRAINT,
|
||
|
|
_integrity_constraint_name,
|
||
|
|
_load_chain,
|
||
|
|
delete_response,
|
||
|
|
get_idempotent_response,
|
||
|
|
get_response,
|
||
|
|
)
|
||
|
|
from sqlalchemy.exc import IntegrityError
|
||
|
|
|
||
|
|
|
||
|
|
class _ScalarResult:
|
||
|
|
def __init__(self, value):
|
||
|
|
self.value = value
|
||
|
|
|
||
|
|
def scalar_one_or_none(self):
|
||
|
|
return self.value
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeSession:
|
||
|
|
def __init__(self, rows, *, selected=None):
|
||
|
|
self.rows = rows
|
||
|
|
self.selected = selected
|
||
|
|
self.commits = 0
|
||
|
|
|
||
|
|
async def __aenter__(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
async def __aexit__(self, *_args):
|
||
|
|
return None
|
||
|
|
|
||
|
|
async def get(self, _model, row_id, **_kwargs):
|
||
|
|
return self.rows.get(row_id)
|
||
|
|
|
||
|
|
async def execute(self, _statement):
|
||
|
|
return _ScalarResult(self.selected)
|
||
|
|
|
||
|
|
async def commit(self):
|
||
|
|
self.commits += 1
|
||
|
|
|
||
|
|
async def refresh(self, _row):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _row(
|
||
|
|
row_id,
|
||
|
|
*,
|
||
|
|
api_key_id="key_a",
|
||
|
|
previous_response_id=None,
|
||
|
|
deleted_at=None,
|
||
|
|
status="completed",
|
||
|
|
):
|
||
|
|
return SimpleNamespace(
|
||
|
|
id=row_id,
|
||
|
|
api_key_id=api_key_id,
|
||
|
|
previous_response_id=previous_response_id,
|
||
|
|
deleted_at=deleted_at,
|
||
|
|
status=status,
|
||
|
|
idempotency_key=None,
|
||
|
|
request_hash=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class ResponseTombstoneTests(unittest.IsolatedAsyncioTestCase):
|
||
|
|
async def test_deleted_ancestor_remains_available_to_existing_child_chain(self):
|
||
|
|
root = _row("resp_root", deleted_at=object())
|
||
|
|
child = _row("resp_child", previous_response_id=root.id)
|
||
|
|
rows = {root.id: root, child.id: child}
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"services.openai_responses.state.SessionLocal",
|
||
|
|
side_effect=lambda: _FakeSession(rows),
|
||
|
|
):
|
||
|
|
chain = await _load_chain(child.id, api_key_id="key_a")
|
||
|
|
self.assertEqual([row.id for row in chain], [root.id, child.id])
|
||
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
||
|
|
await _load_chain(root.id, api_key_id="key_a")
|
||
|
|
self.assertEqual(raised.exception.status_code, 404)
|
||
|
|
|
||
|
|
async def test_deleted_and_cross_key_resources_are_hidden(self):
|
||
|
|
rows = {
|
||
|
|
"resp_deleted": _row("resp_deleted", deleted_at=object()),
|
||
|
|
"resp_other": _row("resp_other", api_key_id="key_b"),
|
||
|
|
}
|
||
|
|
with patch(
|
||
|
|
"services.openai_responses.state.SessionLocal",
|
||
|
|
side_effect=lambda: _FakeSession(rows),
|
||
|
|
):
|
||
|
|
for response_id in rows:
|
||
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
||
|
|
await get_response(response_id, api_key_id="key_a")
|
||
|
|
self.assertEqual(raised.exception.status_code, 404)
|
||
|
|
|
||
|
|
async def test_delete_rejects_in_progress_and_tombstones_terminal_response(self):
|
||
|
|
running = _row("resp_running", status="in_progress")
|
||
|
|
completed = _row("resp_completed")
|
||
|
|
rows = {running.id: running, completed.id: completed}
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"services.openai_responses.state.SessionLocal",
|
||
|
|
side_effect=lambda: _FakeSession(rows),
|
||
|
|
):
|
||
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
||
|
|
await delete_response(running.id, api_key_id="key_a")
|
||
|
|
self.assertEqual(raised.exception.status_code, 409)
|
||
|
|
|
||
|
|
deleted = await delete_response(completed.id, api_key_id="key_a")
|
||
|
|
self.assertIsNotNone(deleted.deleted_at)
|
||
|
|
|
||
|
|
|
||
|
|
class ResponseIdempotencyTests(unittest.IsolatedAsyncioTestCase):
|
||
|
|
def test_only_named_unique_constraint_is_classified_as_idempotency(self):
|
||
|
|
original = RuntimeError("unique violation")
|
||
|
|
original.constraint_name = IDEMPOTENCY_CONSTRAINT
|
||
|
|
error = IntegrityError("insert", {}, original)
|
||
|
|
|
||
|
|
self.assertEqual(
|
||
|
|
_integrity_constraint_name(error),
|
||
|
|
IDEMPOTENCY_CONSTRAINT,
|
||
|
|
)
|
||
|
|
self.assertIsNone(
|
||
|
|
_integrity_constraint_name(
|
||
|
|
IntegrityError("insert", {}, RuntimeError("other failure"))
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
driver_error = RuntimeError("asyncpg unique violation")
|
||
|
|
driver_error.constraint_name = IDEMPOTENCY_CONSTRAINT
|
||
|
|
adapter_error = RuntimeError("SQLAlchemy asyncpg adapter")
|
||
|
|
adapter_error.__cause__ = driver_error
|
||
|
|
self.assertEqual(
|
||
|
|
_integrity_constraint_name(
|
||
|
|
IntegrityError("insert", {}, adapter_error)
|
||
|
|
),
|
||
|
|
IDEMPOTENCY_CONSTRAINT,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def test_same_request_replays_and_different_request_conflicts(self):
|
||
|
|
stored = _row("resp_idempotent")
|
||
|
|
stored.idempotency_key = "request-1"
|
||
|
|
stored.request_hash = "hash-a"
|
||
|
|
|
||
|
|
with patch(
|
||
|
|
"services.openai_responses.state.SessionLocal",
|
||
|
|
side_effect=lambda: _FakeSession({}, selected=stored),
|
||
|
|
):
|
||
|
|
replay = await get_idempotent_response(
|
||
|
|
api_key_id="key_a",
|
||
|
|
idempotency_key="request-1",
|
||
|
|
request_hash="hash-a",
|
||
|
|
)
|
||
|
|
self.assertIs(replay, stored)
|
||
|
|
|
||
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
||
|
|
await get_idempotent_response(
|
||
|
|
api_key_id="key_a",
|
||
|
|
idempotency_key="request-1",
|
||
|
|
request_hash="hash-b",
|
||
|
|
)
|
||
|
|
self.assertEqual(raised.exception.code, "idempotency_key_conflict")
|
||
|
|
self.assertEqual(raised.exception.status_code, 409)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|