feat(backend): Django + OpenCode 백엔드 추가 — 5_django_backend/

앱이 기대하는 base-backend 계약(envelope·JWT·/chat/stream SSE)을 그대로 구현.
Django 는 로그인·세션 미러(SQLite/PG)·OpenCode 이벤트 번역만 맡고, 답변은 전용
OpenCode 인스턴스(opencode/ workspace, codeassist 에이전트)가 만듦.
- accounts: 이메일 로그인, access 60분 / refresh 14일, entra/config 는 501
- chat: 세션 목록·검색·메시지·취소 + POST /chat/stream 어댑터(part.delta→token,
  idle→usage/done, 클라 끊겨도 턴 감시 태스크가 DB 마무리, 첫 이벤트 60초 타임아웃)
- OpenCode 1.18 멀티 프로젝트라 모든 요청에 ?directory= 부착
- docs-lib 에 OpenCode SDK 1.18.6 타입 원본, pytest 32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 21:28:31 +09:00
co-authored by Claude Fable 5.1
parent 08fdc1a258
commit 2869985139
49 changed files with 5884 additions and 5 deletions
View File
+26
View File
@@ -0,0 +1,26 @@
import pytest
from rest_framework.test import APIClient
@pytest.fixture
def api():
return APIClient()
@pytest.fixture
def user(db):
from apps.accounts.models import User
return User.objects.create_user(
username="u@x.com", email="u@x.com", password="pw1234", user_name="유저"
)
@pytest.fixture
def auth_api(api, user):
"""로그인된 클라이언트 + 토큰 응답."""
res = api.post("/api/v1/auth/login", {"email": "u@x.com", "password": "pw1234"}, format="json")
tokens = res.json()["data"]
api.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['token']}")
api.tokens = tokens
return api
+87
View File
@@ -0,0 +1,87 @@
"""인증 API — 프론트 auth.api.ts / types/api.ts 계약."""
TOKEN_KEYS = {
"token",
"tokenExpirationTime",
"refreshToken",
"refreshTokenExpirationTime",
"tokenType",
"user",
}
USER_KEYS = {"id", "email", "userName", "role", "employeeId", "department", "authProvider"}
def test_login_returns_token_response(api, user):
res = api.post("/api/v1/auth/login", {"email": "U@X.com", "password": "pw1234"}, format="json")
assert res.status_code == 200
data = res.json()["data"]
assert set(data) == TOKEN_KEYS
assert data["tokenType"] == "bearer"
assert set(data["user"]) == USER_KEYS
assert data["user"]["email"] == "u@x.com"
assert data["user"]["role"] == "USER"
assert data["user"]["authProvider"] == "local"
assert data["tokenExpirationTime"] < data["refreshTokenExpirationTime"]
def test_login_wrong_password_401_with_code(api, user):
res = api.post("/api/v1/auth/login", {"email": "u@x.com", "password": "nope"}, format="json")
assert res.status_code == 401
assert res.json()["code"] == "INVALID_CREDENTIALS"
def test_me_with_bearer(auth_api):
res = auth_api.get("/api/v1/users/me")
assert res.status_code == 200
data = res.json()["data"]
assert data["email"] == "u@x.com"
assert data["isActive"] is True
assert "createdAt" in data
def test_me_with_query_token(api, auth_api):
# EventSource 등 헤더 못 붙이는 곳용
plain = api.__class__()
res = plain.get(f"/api/v1/users/me?token={auth_api.tokens['token']}")
assert res.status_code == 200
def test_refresh_issues_new_access(auth_api):
old = auth_api.tokens
res = auth_api.post("/api/v1/auth/refresh", {"refreshToken": old["refreshToken"]}, format="json")
assert res.status_code == 200
data = res.json()["data"]
assert set(data) == TOKEN_KEYS
assert data["refreshToken"] == old["refreshToken"] # 회전 안 함
assert data["user"]["email"] == "u@x.com"
def test_refresh_missing_token(api, db):
res = api.post("/api/v1/auth/refresh", {}, format="json")
assert res.status_code == 401
assert res.json()["code"] == "REFRESH_TOKEN_MISSING"
def test_refresh_bad_token(api, db):
res = api.post("/api/v1/auth/refresh", {"refreshToken": "garbage"}, format="json")
assert res.status_code == 401
assert res.json()["code"] == "REFRESH_TOKEN_INVALID"
def test_logout_is_public_and_null(api, db):
res = api.post("/api/v1/auth/logout", format="json")
assert res.status_code == 200
assert res.json()["data"] is None
def test_entra_config_is_501(api, db):
res = api.get("/api/v1/auth/entra/config")
assert res.status_code == 501
assert res.json()["code"] == "NOT_IMPLEMENTED"
def test_seed_users_exist_after_migrate(db):
from apps.accounts.models import User
assert User.objects.filter(email="admin@codeassist.local", is_superuser=True).exists()
assert User.objects.filter(email="guest@codeassist.local").exists()
+29
View File
@@ -0,0 +1,29 @@
"""응답 envelope 가 프론트 CommonResponse 모양인지."""
ENVELOPE_KEYS = {"success", "statusCode", "code", "message", "data", "counts", "errors", "timestamp", "meta"}
def test_health_is_wrapped(api, db):
body = api.get("/api/v1/health").json()
assert set(body) == ENVELOPE_KEYS
assert body["success"] is True and body["statusCode"] == 200
assert body["data"]["app"]
def test_error_is_wrapped_with_code(api, db):
res = api.get("/api/v1/users/me")
body = res.json()
assert res.status_code == 401
assert set(body) == ENVELOPE_KEYS
assert body["success"] is False
assert body["code"] == "UNAUTHORIZED"
assert body["data"] is None
assert body["errors"] and body["message"]
def test_validation_error_is_400(api, db):
res = api.post("/api/v1/auth/login", {"email": "not-an-email"}, format="json")
body = res.json()
assert res.status_code == 400
assert body["code"] == "VALIDATION_ERROR"
assert "password" in body["message"]
+123
View File
@@ -0,0 +1,123 @@
"""세션 미러 API — snap.api.ts 계약. OpenCode 는 가짜로."""
import httpx
import pytest
from apps.chat.models import ChatMessage, ChatSession
SESSION_KEYS = {"id", "title", "titleLlm", "isGenerating", "createdAt", "updatedAt"}
MESSAGE_KEYS = {"sessionId", "role", "content", "createdAt", "inputTokens", "outputTokens", "costUsd", "elapsedMs"}
class FakeOpencode:
def __init__(self):
self.n = 0
self.aborted = []
self.down = False
def create_session(self):
if self.down:
raise httpx.ConnectError("refused")
self.n += 1
return {"id": f"ses_{self.n}", "title": ""}
def abort_session(self, sid):
self.aborted.append(sid)
return True
@pytest.fixture
def oc(monkeypatch):
fake = FakeOpencode()
monkeypatch.setattr("apps.chat.views.opencode_service", fake)
return fake
def _mk(user, sid="ses_a", **kw):
return ChatSession.objects.create(id=sid, user=user, **kw)
def test_create_session_mirrors_opencode(auth_api, oc):
res = auth_api.post("/api/v1/chat/sessions", {}, format="json")
assert res.status_code == 201
data = res.json()["data"]
assert set(data) == SESSION_KEYS
assert data["id"] == "ses_1" and data["isGenerating"] is False and data["titleLlm"] is None
assert ChatSession.objects.get(id="ses_1").user.email == "u@x.com"
def test_create_session_when_opencode_down_503(auth_api, oc):
oc.down = True
res = auth_api.post("/api/v1/chat/sessions", {}, format="json")
assert res.status_code == 503
assert res.json()["code"] == "UPSTREAM_UNAVAILABLE"
def test_list_paginated_newest_first_own_only(auth_api, user, django_user_model):
other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x")
for i in range(5):
_mk(user, f"ses_{i}")
_mk(other, "ses_other")
res = auth_api.get("/api/v1/chat/sessions?page=1&limit=3")
body = res.json()
assert [s["id"] for s in body["data"]] == ["ses_4", "ses_3", "ses_2"]
assert body["counts"] == 5
assert body["meta"] == {
"currentPage": 1,
"pageSize": 3,
"totalItems": 5,
"totalPages": 2,
"hasNextPage": True,
"hasPreviousPage": False,
}
res2 = auth_api.get("/api/v1/chat/sessions?page=2&limit=3")
assert [s["id"] for s in res2.json()["data"]] == ["ses_1", "ses_0"]
def test_messages_detail(auth_api, user):
s = _mk(user, title_llm="첫 질문")
ChatMessage.objects.create(session=s, role="user", content="hi")
ChatMessage.objects.create(
session=s, role="assistant", content="hello", input_tokens=10, output_tokens=5, cost_usd=0.001, elapsed_ms=1200
)
res = auth_api.get("/api/v1/chat/sessions/ses_a/messages")
data = res.json()["data"]
assert set(data) == SESSION_KEYS | {"messages"}
assert data["titleLlm"] == "첫 질문"
assert [m["role"] for m in data["messages"]] == ["user", "assistant"]
assert set(data["messages"][1]) == MESSAGE_KEYS
assert data["messages"][1]["inputTokens"] == 10
assert data["messages"][0]["inputTokens"] is None
def test_messages_of_other_user_404(auth_api, django_user_model):
other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x")
_mk(other, "ses_other")
assert auth_api.get("/api/v1/chat/sessions/ses_other/messages").status_code == 404
def test_search_messages_icontains_own_only(auth_api, user, django_user_model):
other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x")
mine = _mk(user, "ses_m")
theirs = _mk(other, "ses_t")
ChatMessage.objects.create(session=mine, role="user", content="SELECT * FROM mara")
ChatMessage.objects.create(session=mine, role="assistant", content="MARA 는 자재 마스터")
ChatMessage.objects.create(session=mine, role="user", content="다른 얘기")
ChatMessage.objects.create(session=theirs, role="user", content="mara 남의 것")
res = auth_api.get("/api/v1/chat/sessions/search?query=mara&page=1&limit=20")
body = res.json()
assert body["counts"] == 2
assert {m["content"] for m in body["data"]} == {"SELECT * FROM mara", "MARA 는 자재 마스터"}
assert all(m["sessionId"] == "ses_m" for m in body["data"])
def test_search_empty_query_400(auth_api):
assert auth_api.get("/api/v1/chat/sessions/search?query=%20").status_code == 400
def test_cancel_aborts_and_clears_generating(auth_api, user, oc):
_mk(user, is_generating=True)
res = auth_api.post("/api/v1/chat/sessions/ses_a/cancel", {}, format="json")
assert res.status_code == 200 and res.json()["data"] is None
assert oc.aborted == ["ses_a"]
assert ChatSession.objects.get(id="ses_a").is_generating is False
+359
View File
@@ -0,0 +1,359 @@
"""POST /chat/stream — OpenCode 이벤트 → 프론트 SSE 계약(token/title/usage/done/error)."""
import asyncio
import json
import pytest
from django.test import AsyncClient
from apps.chat import events as events_mod
from apps.chat import stream as stream_mod
from apps.chat.models import ChatMessage, ChatSession
from apps.chat.stream import TurnState
# ── TurnState 단위 ───────────────────────────────────────────────
def _part_updated(mid, pid, text, delta=None, ptype="text", sid="s1"):
props = {"part": {"id": pid, "messageID": mid, "sessionID": sid, "type": ptype, "text": text}}
if delta is not None:
props["delta"] = delta
return {"type": "message.part.updated", "properties": props}
def _msg_updated(mid, role, sid="s1", error=None):
info = {"id": mid, "sessionID": sid, "role": role}
if error:
info["error"] = error
return {"type": "message.updated", "properties": {"info": info}}
def test_turnstate_delta_path():
st = TurnState("s1", "q")
st.handle(_msg_updated("mu", "user"))
st.handle(_part_updated("mu", "pu", "q")) # 사용자 echo → 무시
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pa", "", delta="")) == [("token", {"delta": ""})]
assert st.handle(_part_updated("ma", "pa", "안녕", delta="")) == [("token", {"delta": ""})]
assert st.accumulated() == "안녕"
def test_turnstate_snapshot_path_without_delta():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pa", "ab")) == [("token", {"delta": "ab"})]
assert st.handle(_part_updated("ma", "pa", "abcd")) == [("token", {"delta": "cd"})]
assert st.handle(_part_updated("ma", "pa", "abcd")) == [] # 같은 스냅샷 재전송
assert st.accumulated() == "abcd"
def test_turnstate_ignores_reasoning_and_emits_title():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == []
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
placeholder = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "New session - 2026-09-16T08:54:31.272Z"}}}
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
def _delta(mid, pid, delta, sid="s1", field="text"):
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
def test_turnstate_real_server_shape_delta_events():
"""실서버 1.18.6 순서: part.updated(타입, 빈 텍스트) → message.part.delta ×N → part.updated(최종 스냅샷)."""
st = TurnState("s1", "q")
st.handle(_msg_updated("mu", "user"))
st.handle(_part_updated("mu", "pu", "q"))
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]
assert st.handle(_part_updated("ma", "pt", "SELECT SINGLE")) == [] # 최종 스냅샷 = 누적 → 중복 없음
assert st.handle(_part_updated("ma", "pt", "SELECT SINGLE mtart")) == [("token", {"delta": " mtart"})] # 못 받은 꼬리 보충
assert st.handle(_delta("ma", "unknown", "x")) == [] # 타입 모르는 파트
assert st.accumulated() == "SELECT SINGLE mtart"
# ── 통합: 뷰 + 이벤트 버스 + 가짜 OpenCode ───────────────────────
class FakeOpencode:
def __init__(self, script):
self.script = script # prompt 뒤에 흘려보낼 이벤트 dict 목록
self.prompted = asyncio.Event()
self.payloads = []
self.aborted = []
self.fail_prompt = False
self.messages = []
self.session_info = {"id": "s1", "title": ""}
async def prompt_async(self, sid, payload):
self.payloads.append((sid, payload))
if self.fail_prompt:
raise RuntimeError("down")
self.prompted.set()
async def list_messages_a(self, sid):
return self.messages
async def get_session_a(self, sid):
return self.session_info
async def abort_session_a(self, sid):
self.aborted.append(sid)
async def events(self):
await self.prompted.wait()
for ev in self.script:
yield json.dumps(ev)
await asyncio.sleep(3600) # 진짜처럼 계속 열려 있음
@pytest.fixture
def fake(monkeypatch):
def _install(script):
oc = FakeOpencode(script)
monkeypatch.setattr(stream_mod, "opencode_service", oc)
monkeypatch.setattr(events_mod, "event_bus", events_mod.EventBus(source=oc.events))
monkeypatch.setattr(stream_mod, "event_bus", events_mod.event_bus)
return oc
return _install
async def _login(client: AsyncClient, email, pw):
res = await client.post("/api/v1/auth/login", {"email": email, "password": pw}, content_type="application/json")
return res.json()["data"]["token"]
async def _collect(resp):
"""SSE 프레임 → [(event, data)]"""
raw = b""
async for chunk in resp.streaming_content:
raw += chunk
out = []
for frame in raw.decode().split("\n\n"):
if not frame.startswith("event:"):
continue
lines = frame.split("\n")
ev = lines[0][7:]
data = json.loads(lines[1][6:])
out.append((ev, data))
return out
ASSISTANT_DONE = [
_msg_updated("mu", "user"),
_part_updated("mu", "pu", "MARA 뭐야"),
_msg_updated("ma", "assistant"),
_part_updated("ma", "pa", "자재", delta="자재"),
_part_updated("ma", "pa", "자재 마스터", delta=" 마스터"),
{"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 설명"}}},
{"type": "session.idle", "properties": {"sessionID": "s1"}},
]
FINAL_MESSAGES = [
{"info": {"id": "mu", "role": "user"}, "parts": [{"type": "text", "text": "MARA 뭐야"}]},
{
"info": {
"id": "ma",
"role": "assistant",
"tokens": {"input": 120, "output": 30, "reasoning": 0, "cache": {"read": 0, "write": 0}},
"cost": 0.0012,
"time": {"created": 1000, "completed": 3500},
},
"parts": [{"type": "text", "text": "자재 마스터"}],
},
]
@pytest.mark.django_db(transaction=True)
async def test_stream_happy_path(fake, django_user_model):
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
oc = fake(ASSISTANT_DONE)
oc.messages = FINAL_MESSAGES
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
resp = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "MARA 뭐야", "explain": True},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp["Content-Type"].startswith("text/event-stream")
frames = await _collect(resp)
assert [f[0] for f in frames] == ["token", "token", "title", "usage", "done"]
assert "".join(d["delta"] for e, d in frames if e == "token") == "자재 마스터"
assert frames[2][1] == {"title": "MARA 설명"}
assert frames[3][1] == {"used": 150, "limit": 128000, "ratio": round(150 / 128000, 4), "elapsed_ms": 2500}
# OpenCode 에 보낸 payload — agent + explain 표시
sid, payload = oc.payloads[0]
assert sid == "s1" and payload["agent"] == "codeassist"
assert payload["parts"] == [{"type": "text", "text": "[설명 모드] MARA 뭐야"}]
# 미러 DB
session = await ChatSession.objects.aget(id="s1")
assert session.is_generating is False and session.title_llm == "MARA 설명"
msgs = [m async for m in ChatMessage.objects.filter(session_id="s1").order_by("id")]
assert [(m.role, m.content) for m in msgs] == [("user", "MARA 뭐야"), ("assistant", "자재 마스터")]
assert (msgs[1].input_tokens, msgs[1].output_tokens, msgs[1].cost_usd, msgs[1].elapsed_ms) == (120, 30, 0.0012, 2500)
@pytest.mark.django_db(transaction=True)
async def test_stream_images_become_file_parts(fake, django_user_model):
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
oc = fake([_msg_updated("ma", "assistant"), {"type": "session.idle", "properties": {"sessionID": "s1"}}])
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
resp = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "", "images": [{"mediaType": "image/png", "data": "data:image/png;base64,AA=="}]},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
frames = await _collect(resp)
assert frames[-1] == ("done", {})
_, payload = oc.payloads[0]
assert payload["parts"] == [
{"type": "file", "mime": "image/png", "filename": "image-1.png", "url": "data:image/png;base64,AA=="}
]
@pytest.mark.django_db(transaction=True)
async def test_stream_session_error_emits_error_and_clears_generating(fake, django_user_model):
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
fake(
[
_msg_updated("ma", "assistant"),
_part_updated("ma", "pa", "절반", delta="절반"),
{"type": "session.error", "properties": {"sessionID": "s1", "error": {"name": "ApiError", "data": {"message": "rate limited"}}}},
]
)
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
resp = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "q"},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
frames = await _collect(resp)
assert frames == [("token", {"delta": "절반"}), ("error", {"message": "rate limited", "code": "LLM_ERROR"})]
session = await ChatSession.objects.aget(id="s1")
assert session.is_generating is False
last = await ChatMessage.objects.filter(session_id="s1", role="assistant").alast()
assert last.content == "절반" # 부분 답변 보존
@pytest.mark.django_db(transaction=True)
async def test_stream_409_when_generating_and_401_without_token(fake, django_user_model):
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user, is_generating=True)
fake([])
client = AsyncClient()
res = await client.post("/api/v1/chat/stream", {"sessionId": "s1", "content": "q"}, content_type="application/json")
assert res.status_code == 401 and res.json()["code"] == "UNAUTHORIZED"
token = await _login(client, "u@x.com", "pw1234")
res = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "q"},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
assert res.status_code == 409 and res.json()["code"] == "CHAT_GENERATION_IN_PROGRESS"
@pytest.mark.django_db(transaction=True)
async def test_stream_validation(fake, django_user_model):
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
fake([])
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
hdr = {"Authorization": f"Bearer {token}"}
res = await client.post("/api/v1/chat/stream", {"sessionId": "s1", "content": " "}, content_type="application/json", headers=hdr)
assert res.status_code == 400
res = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "x", "images": [{"mediaType": "image/gif", "data": "d"}]},
content_type="application/json",
headers=hdr,
)
assert res.status_code == 400
res = await client.post("/api/v1/chat/stream", {"sessionId": "nope", "content": "x"}, content_type="application/json", headers=hdr)
assert res.status_code == 404
@pytest.mark.django_db(transaction=True)
async def test_stream_no_events_at_all_fails_fast(fake, django_user_model, monkeypatch):
"""OpenCode 가 prompt 는 받았는데(204) 이벤트가 하나도 안 오면 → 첫 이벤트 타임아웃으로 error."""
monkeypatch.setattr(stream_mod, "FIRST_EVENT_TIMEOUT_S", 0.2)
monkeypatch.setattr(stream_mod, "KEEPALIVE_S", 0.05)
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
oc = fake([]) # 이벤트 없음
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
resp = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "q"},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
frames = await _collect(resp)
assert frames[-1][0] == "error" and frames[-1][1]["code"] == "LLM_ERROR"
assert oc.aborted == ["s1"]
session = await ChatSession.objects.aget(id="s1")
assert session.is_generating is False
@pytest.mark.django_db(transaction=True)
async def test_stream_title_arriving_after_idle_is_mirrored(fake, django_user_model, monkeypatch):
monkeypatch.setattr(stream_mod, "TITLE_WAIT_S", 2)
user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x")
user.set_password("pw1234")
await user.asave()
await ChatSession.objects.acreate(id="s1", user=user)
fake(
[
_msg_updated("ma", "assistant"),
{"type": "session.updated", "properties": {"info": {"id": "s1", "title": "New session - 2026"}}},
{"type": "session.idle", "properties": {"sessionID": "s1"}},
{"type": "session.updated", "properties": {"info": {"id": "s1", "title": "늦게 온 제목"}}},
]
)
client = AsyncClient()
token = await _login(client, "u@x.com", "pw1234")
resp = await client.post(
"/api/v1/chat/stream",
{"sessionId": "s1", "content": "q"},
content_type="application/json",
headers={"Authorization": f"Bearer {token}"},
)
frames = await _collect(resp)
assert frames[-1] == ("done", {}) # 제목 기다리느라 done 이 늦어지면 안 됨
await asyncio.sleep(0.3)
session = await ChatSession.objects.aget(id="s1")
assert session.title_llm == "늦게 온 제목"