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) } }