rony-chat-bot/internal/server/conversations_test.go

214 lines
6.1 KiB
Go
Raw Normal View History

feat: persistent conversation storage (Phase 4) Conversations survive page reloads and work for any frontend, not just the widget. Server-side SQLite, conversation ID as bearer token, browser identity via localStorage. Backend ------- - internal/portfolio/conversations.go: schema + CRUD. Conversations and messages tables in the same SQLite DB as the RAG index, with foreign-key cascade delete. Conv IDs are 16-byte random hex (128 bits of entropy). - internal/portfolio/indexer.go: applies conversation schema + enables foreign_keys pragma in OpenStore. - internal/server/handlers.go: POST /api/chat accepts an optional conversation_id, mints one if absent, persists user message before the LLM runs and assistant message (with sources) after the stream completes. New handlers: GetConversation, ListConversations, DeleteConversation. - internal/server/server.go: routes for GET /api/conversations, GET/DELETE /api/conversations/{id}. - internal/server/conversations_test.go: 6 tests (round-trip, continue, list, 404, delete, streaming). Widget ------ - web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"], includes it in the chat request body, captures new IDs from the server's 'start' SSE event, and calls GET /api/conversations/{id} on load to restore history. On 404 it clears the stored ID and starts fresh. Docs ---- - docs/architecture.md: §3.1 documents the conversation_id field and new REST endpoints; new §3.4 covers persistence lifecycle, schema, client responsibilities, and auth model. §5.6 updated; filetree reflects the new files. - web/README.md: new 'Conversation persistence' section explains the browser-scoped behavior and how to opt out or persist across devices.
2026-07-17 07:56:35 +00:00
package server
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
func TestConversationRoundTrip(t *testing.T) {
url, _ := newTestServer(t)
// 1) POST /api/chat with a brand-new session — server creates a conv.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"hola, humano"}],
"stream":false
}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var cr ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
t.Fatal(err)
}
if cr.ConversationID == "" {
t.Fatal("response should include a conversation_id")
}
if cr.Content == "" {
t.Error("response content is empty")
}
// 2) GET /api/conversations/:id — should return both user + assistant messages.
resp2, err := http.Get(url + "/api/conversations/" + cr.ConversationID)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("get conversation status = %d", resp2.StatusCode)
}
var conv portfolio.Conversation
if err := json.NewDecoder(resp2.Body).Decode(&conv); err != nil {
t.Fatal(err)
}
if conv.ID != cr.ConversationID {
t.Errorf("id = %q, want %q", conv.ID, cr.ConversationID)
}
if len(conv.Messages) < 2 {
t.Fatalf("expected >= 2 messages, got %d", len(conv.Messages))
}
if conv.Messages[0].Role != "user" || conv.Messages[0].Content != "hola, humano" {
t.Errorf("first message = %+v", conv.Messages[0])
}
if conv.Messages[1].Role != "assistant" {
t.Errorf("second message role = %q, want assistant", conv.Messages[1].Role)
}
if conv.Messages[1].Content == "" {
t.Error("assistant message content is empty")
}
// Created + updated timestamps should be set
if conv.CreatedAt.IsZero() {
t.Error("created_at is zero")
}
if conv.UpdatedAt.Before(conv.CreatedAt) {
t.Errorf("updated_at (%s) < created_at (%s)", conv.UpdatedAt, conv.CreatedAt)
}
}
func TestConversationContinue(t *testing.T) {
url, _ := newTestServer(t)
// First turn: create the conversation.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"}],
"stream":false
}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr1 ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr1)
resp.Body.Close()
// Second turn: pass the same conversation_id and add a new user message.
body2 := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"},
{"role":"assistant","content":"respuesta 1"},
{"role":"user","content":"segunda"}],
"conversation_id":"` + cr1.ConversationID + `",
"stream":false
}`)
resp2, _ := http.Post(url+"/api/chat", "application/json", body2)
var cr2 ChatResponse
_ = json.NewDecoder(resp2.Body).Decode(&cr2)
resp2.Body.Close()
if cr2.ConversationID != cr1.ConversationID {
t.Errorf("server changed the conversation id: %q → %q", cr1.ConversationID, cr2.ConversationID)
}
// GET should now have 4 messages: user1, assistant1, user2, assistant2.
resp3, _ := http.Get(url + "/api/conversations/" + cr1.ConversationID)
var conv portfolio.Conversation
_ = json.NewDecoder(resp3.Body).Decode(&conv)
resp3.Body.Close()
if len(conv.Messages) != 4 {
t.Errorf("expected 4 messages, got %d", len(conv.Messages))
}
}
func TestListConversations(t *testing.T) {
url, _ := newTestServer(t)
// Create two conversations.
for _, msg := range []string{"primera conversación", "segunda conversación"} {
body := strings.NewReader(`{"messages":[{"role":"user","content":"` + msg + `"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
resp.Body.Close()
}
resp, err := http.Get(url + "/api/conversations")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
var list struct {
Count int `json:"count"`
Conversations []portfolio.ConversationSummary `json:"conversations"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if list.Count < 2 {
t.Errorf("count = %d, want >= 2", list.Count)
}
}
func TestGetConversationNotFound(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/conversations/nonexistent")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
}
func TestDeleteConversation(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"to be deleted"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr)
resp.Body.Close()
del, err := http.NewRequest("DELETE", url+"/api/conversations/"+cr.ConversationID, nil)
if err != nil {
t.Fatal(err)
}
delResp, err := http.DefaultClient.Do(del)
if err != nil {
t.Fatal(err)
}
delResp.Body.Close()
if delResp.StatusCode != 204 {
t.Errorf("delete status = %d, want 204", delResp.StatusCode)
}
// GET should now 404.
get, _ := http.Get(url + "/api/conversations/" + cr.ConversationID)
defer get.Body.Close()
if get.StatusCode != 404 {
t.Errorf("get after delete status = %d, want 404", get.StatusCode)
}
}
func TestChatStreamingIncludesConversationID(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"stream test"}],"stream":true}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
events := parseSSE(t, resp.Body)
if len(events) == 0 {
t.Fatal("no SSE events received")
}
if events[0].event != "start" {
t.Fatalf("first event = %q, want start", events[0].event)
}
var start map[string]any
if err := json.Unmarshal([]byte(events[0].data), &start); err != nil {
t.Fatal(err)
}
if start["conversation_id"] == "" || start["conversation_id"] == nil {
t.Error("start event missing conversation_id")
}
}
// keep the import used
var _ = time.Second