Files
odidere/internal/service/service_test.go
T
2026-10-30 17:21:58 +00:00

441 lines
12 KiB
Go

package service
import (
"context"
"encoding/json/v2"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
"code.chimeric.al/chimerical/odidere/internal/conversation"
"code.chimeric.al/chimerical/odidere/internal/db"
"code.chimeric.al/chimerical/odidere/internal/llm"
)
func newTestService(t *testing.T, handler http.HandlerFunc) *Service {
t.Helper()
if handler == nil {
handler = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"choices":[{
"message":{"role":"assistant","content":"ok"},
"finish_reason":"stop"
}]
}`)
}
}
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
var (
ctx = t.Context()
log = slog.New(slog.DiscardHandler)
)
database, err := db.Connect(ctx, db.Config{
Path: filepath.Join(t.TempDir(), "service.db"),
})
if err != nil {
t.Fatalf("Connect: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := database.ApplySchema(ctx, log); err != nil {
t.Fatalf("ApplySchema: %v", err)
}
cfg := &config.Config{
Address: ":0",
Database: config.Database{
Path: filepath.Join(t.TempDir(), "service.db"),
},
DefaultModel: config.ModelConfig{
Provider: "test",
Model: "test-model",
},
Providers: []config.ProviderConfig{{
Name: "test",
URL: srv.URL,
Models: []string{"test-model"},
}},
STT: config.STT{URL: "http://localhost:8178"},
TTS: config.TTS{URL: "http://localhost:8880", Voice: "af"},
SystemMessage: "Default system.",
}
cfg.ApplyDefaults()
svc, err := New(cfg, log, database)
if err != nil {
t.Fatalf("New: %v", err)
}
return svc
}
func TestStatusHandler(t *testing.T) {
svc := &Service{}
req := httptest.NewRequest(http.MethodGet, "/status", nil)
w := httptest.NewRecorder()
svc.status(w, req)
if w.Code != http.StatusOK {
t.Errorf(
"status handler returned %d, want %d",
w.Code,
http.StatusOK,
)
}
}
func TestConversationHandlers(t *testing.T) {
svc := newTestService(t, nil)
w := httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost, "/conversations",
strings.NewReader(`{"title":"Chat"}`),
))
if w.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201: %s", w.Code, w.Body.String())
}
var created db.Conversation
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if created.Title == nil || *created.Title != "Chat" {
t.Fatalf("Title = %v, want Chat", created.Title)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/conversations", nil,
))
if w.Code != http.StatusOK {
t.Fatalf("list status = %d, want 200", w.Code)
}
var listed []db.Conversation
if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil {
t.Fatalf("Unmarshal list: %v", err)
}
if len(listed) != 1 || listed[0].Id != created.Id {
t.Fatalf("listed = %+v, want [%s]", listed, created.Id)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/conversations/"+created.Id, nil,
))
if w.Code != http.StatusOK {
t.Fatalf("get status = %d, want 200", w.Code)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodDelete, "/conversations/"+created.Id, nil,
))
if w.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204", w.Code)
}
if _, ok := svc.conversations[created.Id]; ok {
t.Fatal("deleted conversation handle remained in map")
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/conversations/"+created.Id, nil,
))
if w.Code != http.StatusNotFound {
t.Fatalf("get deleted status = %d, want 404", w.Code)
}
}
func TestMessageAndToolCallHandlers(t *testing.T) {
svc := newTestService(t, nil)
saved, _, err := svc.newConversation(
t.Context(), "default", nil, "System",
)
if err != nil {
t.Fatalf("newConversation: %v", err)
}
base := "/conversations/" + saved.Id + "/messages"
// Insert and update a user message.
w := httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost, base,
strings.NewReader(`{"message":{"role":"user","content":"hi"}}`),
))
if w.Code != http.StatusCreated {
t.Fatalf("insert user = %d: %s", w.Code, w.Body.String())
}
var user db.Message
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPut, base+"/"+user.Id,
strings.NewReader(fmt.Sprintf(
`{"message":{"role":"user","content":"hello"},"updated_at":%q}`,
user.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusOK {
t.Fatalf("update user = %d: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
// Insert an assistant message, edit its tool call, and edit its result.
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost, base,
strings.NewReader(`{"message":{"role":"assistant","content":"run"}}`),
))
if w.Code != http.StatusCreated {
t.Fatalf("insert assistant = %d: %s", w.Code, w.Body.String())
}
var asst db.Message
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
asstPath := base + "/" + asst.Id
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost, asstPath+"/calls",
strings.NewReader(fmt.Sprintf(
`{"call":{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}},"updated_at":%q}`,
asst.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusOK {
t.Fatalf("add call = %d: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPut, asstPath+"/calls/c1",
strings.NewReader(fmt.Sprintf(
`{"call":{"id":"c1","type":"function","function":{"name":"fn2","arguments":"{}"}},"updated_at":%q}`,
asst.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusOK {
t.Fatalf("update call = %d: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost, base,
strings.NewReader(fmt.Sprintf(
`{"message":{"role":"tool","tool_call_id":"c1","content":"out"},"message_id":%q,"updated_at":%q}`,
asst.Id, asst.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusCreated {
t.Fatalf("add tool result = %d: %s", w.Code, w.Body.String())
}
var result db.Message
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPut, asstPath,
strings.NewReader(fmt.Sprintf(
`{"message":{"role":"tool","tool_call_id":"c1","content":"new"},"updated_at":%q}`,
result.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusOK {
t.Fatalf("update tool result = %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodDelete, asstPath+"/calls/c1",
strings.NewReader(fmt.Sprintf(
`{"updated_at":%q}`,
asst.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusNoContent {
t.Fatalf("delete call = %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodDelete, base+"/"+user.Id,
strings.NewReader(fmt.Sprintf(
`{"updated_at":%q}`,
user.UpdatedAt.Format(time.RFC3339Nano),
)),
))
if w.Code != http.StatusNoContent {
t.Fatalf("delete user = %d: %s", w.Code, w.Body.String())
}
}
func TestExecutionHandlers(t *testing.T) {
svc := newTestService(t, nil)
_, c, err := svc.newConversation(
t.Context(), "default", nil, "System",
)
if err != nil {
t.Fatalf("newConversation: %v", err)
}
asst, err := c.InsertMessage(t.Context(), llm.Message{
Role: llm.RoleAssistant,
ToolCalls: []llm.ToolCall{{
ID: "c1",
Type: llm.ToolTypeFunction,
Function: llm.Function{Name: "fn", Arguments: "{}"},
}},
})
if err != nil {
t.Fatalf("InsertMessage: %v", err)
}
exec, err := svc.dbw.InsertExecution(
t.Context(), db.InsertExecutionParams{
MessageId: asst.Id,
ToolCallId: "c1",
Status: db.ExecutionStatusPending,
},
)
if err != nil {
t.Fatalf("InsertExecution: %v", err)
}
w := httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/executions", nil,
))
if w.Code != http.StatusOK {
t.Fatalf("list status = %d", w.Code)
}
var pending []db.Execution
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if len(pending) != 1 || pending[0].Id != exec.Id {
t.Fatalf("pending = %+v, want [%s]", pending, exec.Id)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPut, "/executions/"+exec.Id,
strings.NewReader(`{"status":"approved"}`),
))
if w.Code != http.StatusNoContent {
t.Fatalf("approve status = %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPut, "/executions/"+exec.Id,
strings.NewReader(`{"status":"denied"}`),
))
if w.Code != http.StatusConflict {
t.Fatalf("second decision status = %d, want 409", w.Code)
}
}
func TestInferenceSubscribeAndStopHandlers(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-release:
case <-r.Context().Done():
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"choices":[{
"message":{"role":"assistant","content":"reply"},
"finish_reason":"stop"
}]
}`)
})
saved, _, err := svc.newConversation(
t.Context(), "default", nil, "System",
)
if err != nil {
t.Fatalf("newConversation: %v", err)
}
// Disconnect the inference client while the background run continues.
reqCtx, cancelReq := context.WithCancel(t.Context())
doneReq := make(chan *httptest.ResponseRecorder, 1)
go func() {
w := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
reqCtx,
http.MethodPost,
"/conversations/"+saved.Id+"/inference",
strings.NewReader(`{"message":{"role":"user","content":"hi"},"stream":false}`),
)
svc.ServeHTTP(w, req)
doneReq <- w
}()
<-started
cancelReq()
<-doneReq
// A second inference while reserved returns 409 Conflict.
w := httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/conversations/"+saved.Id+"/inference",
strings.NewReader(`{"stream":false}`),
))
if w.Code != http.StatusConflict {
t.Fatalf("concurrent inference = %d, want 409", w.Code)
}
close(release)
svc.wg.Wait()
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/conversations/"+saved.Id+"/subscribe",
strings.NewReader(`{}`),
))
if w.Code != http.StatusOK {
t.Fatalf("subscribe status = %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Count(body, "event: "+string(conversation.EventMessageSaved)) != 3 {
t.Fatalf("subscribe events = %s", body)
}
w = httptest.NewRecorder()
svc.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/conversations/"+saved.Id+"/stop",
nil,
))
if w.Code != http.StatusNoContent {
t.Fatalf("stop status = %d, want 204", w.Code)
}
}