Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
type copilotParityFixture struct {
|
||||
db *gorm.DB
|
||||
router *gin.Engine
|
||||
otherUserRouter *gin.Engine
|
||||
account *model.Account
|
||||
otherAccount *model.Account
|
||||
user *model.User
|
||||
otherUser *model.User
|
||||
assistant *model.CaptainAssistant
|
||||
otherAssistant *model.CaptainAssistant
|
||||
}
|
||||
|
||||
func newCopilotParityFixture(t *testing.T) *copilotParityFixture {
|
||||
return newCopilotParityFixtureWithProvider(t, nil)
|
||||
}
|
||||
|
||||
func newCopilotParityFixtureWithProvider(t *testing.T, provider llm.Provider) *copilotParityFixture {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.CaptainAssistant{},
|
||||
&model.CopilotThread{},
|
||||
&model.CopilotMessage{},
|
||||
&model.CopilotSuggestionMessage{},
|
||||
))
|
||||
|
||||
account := &model.Account{Name: "Copilot Org", Locale: "en", Active: true}
|
||||
otherAccount := &model.Account{Name: "Other Org", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
require.NoError(t, db.Create(otherAccount).Error)
|
||||
|
||||
user := &model.User{AccountID: account.ID, Name: "Agent One", DisplayName: "Agent", Email: "agent@example.com", Password: "secret", Active: true, Available: true}
|
||||
otherUser := &model.User{AccountID: account.ID, Name: "Agent Two", Email: "agent2@example.com", Password: "secret", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
require.NoError(t, db.Create(otherUser).Error)
|
||||
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Helper", Description: "Primary assistant", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
|
||||
otherAssistant := &model.CaptainAssistant{AccountID: otherAccount.ID, Name: "Other", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
require.NoError(t, db.Create(otherAssistant).Error)
|
||||
|
||||
threadRepo := repository.NewCopilotThreadRepo(db)
|
||||
messageRepo := repository.NewCopilotMessageRepo(db)
|
||||
suggestionRepo := repository.NewCopilotSuggestionRepo(db)
|
||||
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
||||
handler := NewCopilotHandler(service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, provider, assistantRepo))
|
||||
|
||||
fixture := &copilotParityFixture{
|
||||
db: db,
|
||||
account: account,
|
||||
otherAccount: otherAccount,
|
||||
user: user,
|
||||
otherUser: otherUser,
|
||||
assistant: assistant,
|
||||
otherAssistant: otherAssistant,
|
||||
}
|
||||
fixture.router = copilotRouterForUser(handler, user.ID)
|
||||
fixture.otherUserRouter = copilotRouterForUser(handler, otherUser.ID)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
require.NoError(t, dbErr)
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
return fixture
|
||||
}
|
||||
|
||||
func copilotRouterForUser(handler *CopilotHandler, userID uint) *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
})
|
||||
accounts := router.Group("/api/v1/accounts/:account_id")
|
||||
captain := accounts.Group("/captain")
|
||||
threads := captain.Group("/copilot_threads")
|
||||
threads.GET("/", handler.ListThreads)
|
||||
threads.POST("/", handler.CreateThread)
|
||||
threads.GET("/:thread_id", handler.GetThread)
|
||||
threads.DELETE("/:thread_id", handler.DeleteThread)
|
||||
messages := threads.Group("/:thread_id/copilot_messages")
|
||||
messages.GET("/", handler.ListSuggestionMessages)
|
||||
messages.POST("/", handler.SendMessage)
|
||||
return router
|
||||
}
|
||||
|
||||
func (f *copilotParityFixture) request(router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
raw, _ = json.Marshal(body)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(method, path, bytes.NewReader(raw))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func (f *copilotParityFixture) captainPath(path string) string {
|
||||
return "/api/v1/accounts/" + uintString(f.account.ID) + "/captain" + path
|
||||
}
|
||||
|
||||
func (f *copilotParityFixture) createThread(t *testing.T, message string) map[string]any {
|
||||
t.Helper()
|
||||
w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{
|
||||
"message": message,
|
||||
"assistant_id": f.assistant.ID,
|
||||
"conversation_id": 123,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
return decodeMap(t, w)
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func uintString(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func TestCopilotThreadCreateReturnsChatwootPayload(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
|
||||
payload := f.createThread(t, "Need help")
|
||||
require.Nil(t, payload["success"])
|
||||
require.Equal(t, "Need help", payload["title"])
|
||||
require.Equal(t, float64(f.account.ID), payload["account_id"])
|
||||
|
||||
user := payload["user"].(map[string]any)
|
||||
require.Equal(t, float64(f.user.ID), user["id"])
|
||||
require.Equal(t, "user", user["type"])
|
||||
require.Equal(t, "online", user["availability_status"])
|
||||
|
||||
assistant := payload["assistant"].(map[string]any)
|
||||
require.Equal(t, float64(f.assistant.ID), assistant["id"])
|
||||
require.Equal(t, "captain_assistant", assistant["type"])
|
||||
|
||||
var count int64
|
||||
require.NoError(t, f.db.Model(&model.CopilotMessage{}).Count(&count).Error)
|
||||
require.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
func TestCopilotThreadCreateValidationAndAssistantScope(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
|
||||
w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"assistant_id": f.assistant.ID})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
|
||||
require.Equal(t, "Message is required", decodeMap(t, w)["error"])
|
||||
|
||||
w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello"})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
|
||||
require.Equal(t, "assistant_id is required", decodeMap(t, w)["error"])
|
||||
|
||||
w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello", "assistant_id": f.otherAssistant.ID})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
|
||||
|
||||
var count int64
|
||||
require.NoError(t, f.db.Model(&model.CopilotThread{}).Count(&count).Error)
|
||||
require.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestCopilotThreadListIsUserScopedAndOrdered(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
first := f.createThread(t, "First")
|
||||
second := f.createThread(t, "Second")
|
||||
firstID := uint(first["id"].(float64))
|
||||
secondID := uint(second["id"].(float64))
|
||||
require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", firstID).Update("created_at", time.Now().Add(-time.Hour)).Error)
|
||||
require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", secondID).Update("created_at", time.Now()).Error)
|
||||
|
||||
w := f.request(f.otherUserRouter, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{
|
||||
"message": "Other user",
|
||||
"assistant_id": f.assistant.ID,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
w = f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/?page=1"), nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
payload := decodeMap(t, w)["payload"].([]any)
|
||||
require.Len(t, payload, 2)
|
||||
require.Equal(t, second["id"], payload[0].(map[string]any)["id"])
|
||||
require.Equal(t, first["id"], payload[1].(map[string]any)["id"])
|
||||
}
|
||||
|
||||
func TestCopilotThreadMessagesListAndCreateUseNestedPayloads(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
thread := f.createThread(t, "Need help")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
|
||||
|
||||
w := f.request(f.router, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
messages := decodeMap(t, w)["payload"].([]any)
|
||||
require.Len(t, messages, 2)
|
||||
require.Equal(t, "user", messages[0].(map[string]any)["message_type"])
|
||||
require.Equal(t, "assistant", messages[1].(map[string]any)["message_type"])
|
||||
require.NotNil(t, messages[0].(map[string]any)["copilot_thread"])
|
||||
|
||||
w = f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
created := decodeMap(t, w)
|
||||
require.Nil(t, created["success"])
|
||||
require.Equal(t, "user", created["message_type"])
|
||||
require.Equal(t, "Follow up", created["message"].(map[string]any)["content"])
|
||||
|
||||
w = f.request(f.router, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
messages = decodeMap(t, w)["payload"].([]any)
|
||||
require.Len(t, messages, 4)
|
||||
}
|
||||
|
||||
func TestCopilotThreadCreateProviderDisabledReturnsStableUnavailableAssistantMessage(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
|
||||
thread := f.createThread(t, "Need help")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
w := f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/"+threadID+"/copilot_messages/"), nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
messages := decodeMap(t, w)["payload"].([]any)
|
||||
require.Len(t, messages, 2)
|
||||
assistant := messages[1].(map[string]any)
|
||||
require.Equal(t, "assistant", assistant["message_type"])
|
||||
require.Equal(t, service.CopilotUnavailableMessage, assistant["message"].(map[string]any)["content"])
|
||||
require.Nil(t, assistant["success"])
|
||||
}
|
||||
|
||||
func TestCopilotThreadCreateProviderEnabledPersistsGeneratedAssistantMessage(t *testing.T) {
|
||||
provider := &copilotFakeProvider{content: "Generated copilot answer"}
|
||||
f := newCopilotParityFixtureWithProvider(t, provider)
|
||||
|
||||
thread := f.createThread(t, "Need help")
|
||||
require.NotEmpty(t, provider.lastRequest.Messages)
|
||||
require.Equal(t, "system", provider.lastRequest.Messages[0].Role)
|
||||
require.Equal(t, "Need help", provider.lastRequest.Messages[len(provider.lastRequest.Messages)-1].Content)
|
||||
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
w := f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/"+threadID+"/copilot_messages/"), nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
messages := decodeMap(t, w)["payload"].([]any)
|
||||
require.Len(t, messages, 2)
|
||||
assistant := messages[1].(map[string]any)
|
||||
require.Equal(t, "assistant", assistant["message_type"])
|
||||
require.Equal(t, "Generated copilot answer", assistant["message"].(map[string]any)["content"])
|
||||
}
|
||||
|
||||
func TestCopilotMessagePayloadUsesThreadPushShape(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
thread := f.createThread(t, "Need help")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
|
||||
|
||||
w := f.request(f.router, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
messages := decodeMap(t, w)["payload"].([]any)
|
||||
message := messages[0].(map[string]any)
|
||||
nestedThread := message["copilot_thread"].(map[string]any)
|
||||
|
||||
require.Equal(t, thread["id"], nestedThread["id"])
|
||||
require.NotNil(t, nestedThread["user"])
|
||||
require.Equal(t, thread["account_id"], nestedThread["account_id"])
|
||||
require.Nil(t, nestedThread["assistant"])
|
||||
require.NotNil(t, message["account_id"])
|
||||
}
|
||||
|
||||
func TestCopilotMessagePushPayloadMatchesChatwootEventData(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
thread := f.createThread(t, "Need help")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
|
||||
w := f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123})
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
created := decodeMap(t, w)
|
||||
|
||||
var stored model.CopilotMessage
|
||||
require.NoError(t, f.db.Preload("CopilotThread.User").Preload("CopilotThread.Assistant").First(&stored, uint(created["id"].(float64))).Error)
|
||||
push := copilotMessagePushPayload(&stored)
|
||||
nestedThread := push["copilot_thread"].(gin.H)
|
||||
|
||||
require.Equal(t, created["id"], float64(push["id"].(uint)))
|
||||
require.Equal(t, "user", string(push["message_type"].(model.CopilotMessageType)))
|
||||
require.Equal(t, "Follow up", push["message"].(map[string]any)["content"])
|
||||
require.Nil(t, push["account_id"])
|
||||
require.Equal(t, stored.CopilotThreadID, nestedThread["id"])
|
||||
require.Nil(t, nestedThread["assistant"])
|
||||
require.NotNil(t, nestedThread["user"])
|
||||
}
|
||||
|
||||
func TestCopilotThreadMessagesAreAccountAndUserScoped(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
thread := f.createThread(t, "Private thread")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
|
||||
|
||||
w := f.request(f.otherUserRouter, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
|
||||
|
||||
otherAccountPath := "/api/v1/accounts/" + uintString(f.otherAccount.ID) + "/captain/copilot_threads/" + threadID + "/copilot_messages/"
|
||||
w = f.request(f.router, http.MethodGet, otherAccountPath, nil)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
func TestCopilotThreadGetAndDeleteAreScoped(t *testing.T) {
|
||||
f := newCopilotParityFixture(t)
|
||||
thread := f.createThread(t, "Delete me")
|
||||
threadID := uintString(uint(thread["id"].(float64)))
|
||||
path := f.captainPath("/copilot_threads/" + threadID)
|
||||
|
||||
w := f.request(f.router, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.Equal(t, "Delete me", decodeMap(t, w)["title"])
|
||||
|
||||
w = f.request(f.otherUserRouter, http.MethodDelete, path, nil)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
|
||||
|
||||
w = f.request(f.router, http.MethodDelete, path, nil)
|
||||
require.Equal(t, http.StatusNoContent, w.Code, w.Body.String())
|
||||
|
||||
w = f.request(f.router, http.MethodGet, path, nil)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
type copilotFakeProvider struct {
|
||||
content string
|
||||
lastRequest llm.ChatRequest
|
||||
}
|
||||
|
||||
func (p *copilotFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
p.lastRequest = req
|
||||
return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: p.content}}}}, nil
|
||||
}
|
||||
|
||||
func (p *copilotFakeProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
||||
return &llm.EmbeddingResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *copilotFakeProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user