Files
learningcircuit--local-deep…/tests/chat/test_chat_attempt_delete_retry.py
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

758 lines
28 KiB
Python

# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client
"""Tests for per-attempt delete + retry endpoints.
Issue #4659: when a chat-triggered research fails (OoM, crash, dead LLM
endpoint), the user message stays in the thread and there's no in-chat
way to retry without copy-pasting. The new endpoints
DELETE /api/chat/sessions/<sid>/attempts/<rid>
POST /api/chat/sessions/<sid>/attempts/<rid>/retry
let the client delete or re-run a single attempt atomically.
Coverage in this file:
- Service-level (``ChatService.delete_attempt`` /
``get_original_attempt_query``): happy path, missing, in-progress
refusal, stale-IN_PROGRESS reclaim, message_count decrement,
research_meta fallback.
- HTTP-level: happy paths, 404 / 409 mappings, CSRF enforcement,
concurrent-message 409 race via the partial unique index.
"""
import json
import uuid
from datetime import datetime, UTC
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from src.local_deep_research.chat.service import (
AttemptInProgress,
AttemptNotFound,
ChatService,
)
from src.local_deep_research.constants import ResearchStatus
from src.local_deep_research.database.models import (
ChatMessage,
ChatProgressStep,
ChatRole,
ChatSession,
ChatSessionStatus,
ResearchHistory,
)
# ---------------------------------------------------------------------------
# Seed helpers — used by both service-level and HTTP-level tests.
# ---------------------------------------------------------------------------
@contextmanager
def _service_db(SessionLocal):
"""Redirect ``ChatService.get_user_db_session`` to the test DB.
ChatService opens the per-user encrypted DB by username; the test
DB from ``setup_database_for_all_tests`` is a plain SQLite file.
Same pattern as ``tests/chat/test_chat_delete_terminates_research.py``.
"""
with patch(
"src.local_deep_research.chat.service.get_user_db_session"
) as ctx:
ctx.return_value.__enter__.return_value = SessionLocal()
ctx.return_value.__exit__.return_value = False
yield
def _seed_attempt(
db,
username: str,
session_id: str,
research_id: str,
*,
research_status: str = ResearchStatus.FAILED.value,
user_content: str = "What is quantum computing?",
assistant_content: str | None = "Quantum computing uses qubits...",
include_research_meta: bool = True,
user_message_id: str | None = None,
):
"""Insert a full attempt (user msg + research + optional assistant msg).
Returns ``(user_message_id, assistant_message_id_or_None)``.
"""
# Look up the session to find the next sequence_number.
session = db.query(ChatSession).filter_by(id=session_id).first()
next_seq = (session.message_count if session else 0) + 1
user_msg_id = user_message_id or str(uuid.uuid4())
# Mirror production: _spawn_chat_research inserts the user message with
# research_id=NULL and links it to the attempt only via
# research_meta.submission.message_id. Only legacy rows that predate the
# research_meta field carried research_id on the user message — model
# those when include_research_meta is False so the fallback lookup path
# in get_original_attempt_query / delete_attempt stays exercised.
user_research_id = None if include_research_meta else research_id
db.add(
ChatMessage(
id=user_msg_id,
session_id=session_id,
research_id=user_research_id,
role=ChatRole.USER.value,
message_type="query",
content=user_content,
sequence_number=next_seq,
)
)
research_meta = None
if include_research_meta:
research_meta = {
"submission": {
"chat_session_id": session_id,
"message_id": user_msg_id,
"research_mode": "quick",
}
}
db.add(
ResearchHistory(
id=research_id,
query=user_content,
mode="quick",
status=research_status,
created_at=datetime.now(UTC).isoformat(),
chat_session_id=session_id,
research_meta=research_meta,
)
)
assistant_msg_id = None
if assistant_content is not None:
assistant_msg_id = str(uuid.uuid4())
db.add(
ChatMessage(
id=assistant_msg_id,
session_id=session_id,
research_id=research_id,
role=ChatRole.ASSISTANT.value,
message_type="response",
content=assistant_content,
sequence_number=next_seq + 1,
)
)
# A progress step tied to this research.
db.add(
ChatProgressStep(
id=str(uuid.uuid4()),
research_id=research_id,
session_id=session_id,
phase="search",
content="Searching...",
sequence_number=1,
)
)
# Bump session message_count to match what we inserted.
if session:
session.message_count = session.message_count + (
2 if assistant_content is not None else 1
)
db.commit()
return user_msg_id, assistant_msg_id
def _make_session(db, username: str, title: str = "test chat") -> str:
session_id = str(uuid.uuid4())
db.add(
ChatSession(
id=session_id,
title=title,
status=ChatSessionStatus.ACTIVE.value,
message_count=0,
)
)
db.commit()
return session_id
# ===========================================================================
# Service-level: ChatService.delete_attempt
# ===========================================================================
class TestDeleteAttemptService:
"""Direct ChatService.delete_attempt tests."""
def test_delete_attempt_happy_path(self, setup_database_for_all_tests):
"""Deleting a FAILED attempt removes the user msg, assistant msg,
progress step, and research row; decrements message_count by 2."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
user_msg_id, assistant_msg_id = _seed_attempt(
db,
username,
session_id,
research_id,
research_status=ResearchStatus.FAILED.value,
)
# Sanity: 2 messages (user + assistant) + 1 step + 1 research
# exist. Count by session_id, NOT research_id — the user message
# is seeded with research_id=NULL (production behaviour), so a
# research_id filter would only see the assistant row.
assert (
db.query(ChatMessage).filter_by(session_id=session_id).count()
== 2
)
# Regression guard for #4659: the user message must be linked
# to the attempt only via research_meta, with research_id NULL.
user_row = db.query(ChatMessage).filter_by(id=user_msg_id).first()
assert user_row is not None
assert user_row.research_id is None
assert (
db.query(ChatProgressStep)
.filter_by(research_id=research_id)
.count()
== 1
)
with _service_db(SessionLocal):
result = service.delete_attempt(session_id, research_id)
assert result is True
with SessionLocal() as db:
# All attempt rows gone — including the NULL-research_id user
# message (the #4659 bug left it orphaned in the thread).
assert (
db.query(ChatMessage).filter_by(session_id=session_id).count()
== 0
)
assert (
db.query(ChatMessage).filter_by(id=user_msg_id).first() is None
)
assert (
db.query(ChatProgressStep)
.filter_by(research_id=research_id)
.count()
== 0
)
assert (
db.query(ResearchHistory).filter_by(id=research_id).first()
is None
)
# Session still exists and message_count was decremented.
session = db.query(ChatSession).filter_by(id=session_id).first()
assert session is not None
assert session.message_count == 0
def test_delete_attempt_failed_no_assistant_removes_user_msg(
self, setup_database_for_all_tests
):
"""Primary #4659 case: a FAILED attempt with NO assistant reply.
The user message is the only chat row and is stored with
research_id=NULL (linked via research_meta.submission.message_id).
Before the fix, delete_attempt filtered solely on research_id, so it
removed nothing user-visible and never decremented message_count —
leaving an orphaned user bubble. Delete must remove the user message
and decrement the counter by exactly 1.
"""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
user_msg_id, assistant_msg_id = _seed_attempt(
db,
username,
session_id,
research_id,
research_status=ResearchStatus.FAILED.value,
assistant_content=None,
)
assert assistant_msg_id is None
# Only the user message exists, and it has NULL research_id.
assert (
db.query(ChatMessage).filter_by(session_id=session_id).count()
== 1
)
user_row = db.query(ChatMessage).filter_by(id=user_msg_id).first()
assert user_row is not None and user_row.research_id is None
assert (
db.query(ChatSession)
.filter_by(id=session_id)
.first()
.message_count
== 1
)
with _service_db(SessionLocal):
assert service.delete_attempt(session_id, research_id) is True
with SessionLocal() as db:
assert (
db.query(ChatMessage).filter_by(id=user_msg_id).first() is None
)
assert (
db.query(ChatMessage).filter_by(session_id=session_id).count()
== 0
)
assert (
db.query(ResearchHistory).filter_by(id=research_id).first()
is None
)
# The counter was decremented by exactly 1 (the orphaned user
# bubble), not left drifting at 1.
assert (
db.query(ChatSession)
.filter_by(id=session_id)
.first()
.message_count
== 0
)
def test_delete_attempt_missing(self, setup_database_for_all_tests):
"""Unknown research_id → AttemptNotFound."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
with _service_db(SessionLocal):
with pytest.raises(AttemptNotFound):
service.delete_attempt(session_id, str(uuid.uuid4()))
def test_delete_attempt_wrong_session(self, setup_database_for_all_tests):
"""Research exists but belongs to a different session_id →
AttemptNotFound (scoped lookup refuses to return cross-session
rows)."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_a = _make_session(db, username, title="A")
session_b = _make_session(db, username, title="B")
research_id = str(uuid.uuid4())
_seed_attempt(
db,
username,
session_a,
research_id,
research_status=ResearchStatus.FAILED.value,
)
# Asking for the research under session_b must 404.
with _service_db(SessionLocal):
with pytest.raises(AttemptNotFound):
service.delete_attempt(session_b, research_id)
def test_delete_attempt_in_progress_with_live_thread(
self, setup_database_for_all_tests
):
"""IN_PROGRESS + alive thread → AttemptInProgress (409 mapping)
and the worker is flagged for termination."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
_seed_attempt(
db,
username,
session_id,
research_id,
research_status=ResearchStatus.IN_PROGRESS.value,
)
flagged = []
with (
patch(
"src.local_deep_research.chat.service.set_termination_flag",
side_effect=lambda rid: flagged.append(rid),
),
patch(
"src.local_deep_research.chat.service.is_research_thread_alive",
return_value=True,
),
_service_db(SessionLocal),
):
with pytest.raises(AttemptInProgress):
service.delete_attempt(session_id, research_id)
# Worker was flagged before the raise.
assert flagged == [research_id]
# Row still exists — we refused to hard-delete.
with SessionLocal() as db:
assert (
db.query(ResearchHistory).filter_by(id=research_id).first()
is not None
)
def test_delete_attempt_stale_in_progress_reclaims(
self, setup_database_for_all_tests
):
"""IN_PROGRESS but thread is dead → reclaimed and deleted (200),
not 409."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
_seed_attempt(
db,
username,
session_id,
research_id,
research_status=ResearchStatus.IN_PROGRESS.value,
)
# is_research_thread_alive returns False → sweep reclaims.
with (
patch(
"src.local_deep_research.chat.service.is_research_thread_alive",
return_value=False,
),
_service_db(SessionLocal),
):
assert service.delete_attempt(session_id, research_id) is True
with SessionLocal() as db:
assert (
db.query(ResearchHistory).filter_by(id=research_id).first()
is None
)
# ===========================================================================
# Service-level: ChatService.get_original_attempt_query
# ===========================================================================
class TestGetOriginalAttemptQuery:
"""Direct ChatService.get_original_attempt_query tests."""
def test_uses_research_meta_message_id_fast_path(
self, setup_database_for_all_tests
):
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
_seed_attempt(
db,
username,
session_id,
research_id,
user_content="Probe question",
include_research_meta=True,
)
with _service_db(SessionLocal):
result = service.get_original_attempt_query(session_id, research_id)
assert result == "Probe question"
def test_falls_back_to_chatmessage_query_when_meta_missing(
self, setup_database_for_all_tests
):
"""Older rows predate research_meta; the fallback lookup by
ChatMessage.research_id + role='user' must succeed."""
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
research_id = str(uuid.uuid4())
_seed_attempt(
db,
username,
session_id,
research_id,
user_content="Legacy query",
include_research_meta=False,
)
with _service_db(SessionLocal):
result = service.get_original_attempt_query(session_id, research_id)
assert result == "Legacy query"
def test_missing_research_raises(self, setup_database_for_all_tests):
SessionLocal = setup_database_for_all_tests
username = f"alice_{uuid.uuid4().hex[:8]}"
service = ChatService(username=username)
with SessionLocal() as db:
session_id = _make_session(db, username)
with _service_db(SessionLocal):
with pytest.raises(AttemptNotFound):
service.get_original_attempt_query(
session_id, str(uuid.uuid4())
)
# ===========================================================================
# HTTP-level: DELETE /api/chat/sessions/<sid>/attempts/<rid>
# ===========================================================================
class TestDeleteAttemptRoute:
"""HTTP tests for the delete-attempt endpoint."""
def test_delete_happy_path(self, authenticated_client):
"""DELETE removes the attempt and returns 200."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Drive a real send_message with mocked spawn so we get a real
# ResearchHistory + ChatMessage + step row to delete.
with patch("local_deep_research.chat.routes.start_research_process"):
send_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": "what is X?", "trigger_research": True},
content_type="application/json",
)
assert send_resp.status_code == 200
research_id = json.loads(send_resp.data)["research_id"]
assert research_id is not None
# Sanity: research exists, message_count is at least 1.
get_resp = authenticated_client.get(f"/api/chat/sessions/{session_id}")
assert get_resp.status_code == 200
assert json.loads(get_resp.data)["session"]["message_count"] >= 1
# Now delete the attempt.
del_resp = authenticated_client.delete(
f"/api/chat/sessions/{session_id}/attempts/{research_id}"
)
assert del_resp.status_code == 200
assert json.loads(del_resp.data)["success"] is True
def test_delete_missing_research_returns_404(self, authenticated_client):
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
del_resp = authenticated_client.delete(
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}"
)
assert del_resp.status_code == 404
def test_delete_missing_session_returns_404(self, authenticated_client):
"""Unknown session_id → 404 (research can't belong to it)."""
del_resp = authenticated_client.delete(
f"/api/chat/sessions/{uuid.uuid4()}/attempts/{uuid.uuid4()}"
)
assert del_resp.status_code == 404
def test_delete_in_progress_returns_409(self, authenticated_client):
"""IN_PROGRESS research with a live thread → 409 with
active_research_id."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
with patch("local_deep_research.chat.routes.start_research_process"):
send_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": "what is X?", "trigger_research": True},
content_type="application/json",
)
research_id = json.loads(send_resp.data)["research_id"]
# Patch is_research_thread_alive to True so delete_attempt
# treats the just-spawned research as live.
with patch(
"local_deep_research.chat.service.is_research_thread_alive",
return_value=True,
):
del_resp = authenticated_client.delete(
f"/api/chat/sessions/{session_id}/attempts/{research_id}"
)
assert del_resp.status_code == 409
body = json.loads(del_resp.data)
assert body["success"] is False
assert body["active_research_id"] == research_id
# ===========================================================================
# HTTP-level: POST /api/chat/sessions/<sid>/attempts/<rid>/retry
# ===========================================================================
class TestRetryAttemptRoute:
"""HTTP tests for the retry-attempt endpoint."""
def test_retry_happy_path_returns_new_research_id(
self, authenticated_client
):
"""Retry of a FAILED attempt: returns 200 + a fresh research_id
+ message_id."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
with patch("local_deep_research.chat.routes.start_research_process"):
send_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": "what is X?", "trigger_research": True},
content_type="application/json",
)
old_research_id = json.loads(send_resp.data)["research_id"]
# The retry route's stale-row sweep only fires for rows older
# than the grace window (30s); the just-spawned research is
# brand-new so the sweep skips it and the per-session guard
# would refuse with 409. Patch the cap check to short-circuit
# so we can exercise the actual delete + re-spawn path. The
# cap-check itself is covered by the dedicated 409 test below.
# Also patch is_research_thread_alive=False so delete_attempt's
# own IN_PROGRESS check reclaims the row instead of refusing.
with (
patch(
"local_deep_research.chat.routes._enforce_chat_session_research_slot",
return_value=None,
),
patch(
"local_deep_research.chat.service.is_research_thread_alive",
return_value=False,
),
patch("local_deep_research.chat.routes.start_research_process"),
):
retry_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/attempts/{old_research_id}/retry"
)
assert retry_resp.status_code == 200, retry_resp.data
body = json.loads(retry_resp.data)
assert body["success"] is True
new_research_id = body["research_id"]
new_message_id = body["message_id"]
assert new_research_id is not None
assert new_message_id is not None
# The retry produced a DIFFERENT research id (the old one was
# deleted; new UUID allocated).
assert new_research_id != old_research_id
def test_retry_missing_research_returns_404(self, authenticated_client):
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
retry_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
)
assert retry_resp.status_code == 404
def test_retry_on_missing_session_returns_404(self, authenticated_client):
retry_resp = authenticated_client.post(
f"/api/chat/sessions/{uuid.uuid4()}/attempts/{uuid.uuid4()}/retry"
)
assert retry_resp.status_code == 404
def test_retry_archived_session_returns_409(self, authenticated_client):
"""Archived sessions are read-only; retry must refuse."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Archive it.
archived = authenticated_client.patch(
f"/api/chat/sessions/{session_id}",
json={"status": "archived"},
content_type="application/json",
)
assert archived.status_code == 200
retry_resp = authenticated_client.post(
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
)
assert retry_resp.status_code == 409
# ===========================================================================
# CSRF enforcement
# ===========================================================================
class TestAttemptEndpointsCSRF:
"""DELETE and POST retry must reject requests without a CSRF token."""
def test_delete_without_csrf_token_rejected(
self, csrf_authenticated_client
):
client, token = csrf_authenticated_client
create_resp = client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
headers={"X-CSRFToken": token},
)
assert create_resp.status_code == 200
session_id = json.loads(create_resp.data)["session_id"]
# DELETE without token — must be rejected.
response = client.delete(
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}"
)
assert response.status_code == 400
def test_retry_without_csrf_token_rejected(self, csrf_authenticated_client):
client, token = csrf_authenticated_client
create_resp = client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
headers={"X-CSRFToken": token},
)
assert create_resp.status_code == 200
session_id = json.loads(create_resp.data)["session_id"]
# POST retry without token — must be rejected.
response = client.post(
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
)
assert response.status_code == 400