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:
@@ -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
|
||||
Reference in New Issue
Block a user