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,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupCopilotResponseWorkerTest(t *testing.T) (*gorm.DB, *CopilotService, *model.Account, *model.User, *model.CaptainAssistant) {
|
||||
t.Helper()
|
||||
dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.CaptainAssistant{}, &model.CopilotThread{}, &model.CopilotMessage{}, &model.CopilotSuggestionMessage{}, &model.BackgroundJob{}))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
account := &model.Account{Name: "Copilot Org", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Helper", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
|
||||
require.NoError(t, db.Create(assistant).Error)
|
||||
svc := NewCopilotService(repository.NewCopilotThreadRepo(db), repository.NewCopilotMessageRepo(db), repository.NewCopilotSuggestionRepo(db), nil, repository.NewCaptainAssistantRepo(db))
|
||||
return db, svc, account, user, assistant
|
||||
}
|
||||
|
||||
func TestCopilotResponseJobQueuesAndPersistsAssistantReply(t *testing.T) {
|
||||
db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t)
|
||||
svc.SetResponseBackend(&fakeCopilotResponseBackend{messages: []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": "Here is the answer"}}}})
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
|
||||
thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID, ConversationID: 123})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, thread)
|
||||
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CopilotMessage{}).Where("copilot_thread_id = ?", thread.ID).Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count)
|
||||
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeCaptainCopilotResponse, model.BackgroundJobStatusQueued).Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, processed)
|
||||
|
||||
var messages []model.CopilotMessage
|
||||
require.NoError(t, db.Where("copilot_thread_id = ?", thread.ID).Order("id ASC").Find(&messages).Error)
|
||||
require.Len(t, messages, 2)
|
||||
assert.Equal(t, model.CopilotMessageTypeUser, messages[0].MessageType)
|
||||
assert.Equal(t, model.CopilotMessageTypeAssistant, messages[1].MessageType)
|
||||
assert.Equal(t, "Here is the answer", messages[1].GetMessageContent())
|
||||
}
|
||||
|
||||
func TestCopilotResponseJobUsesDisabledFallbackWithoutProvider(t *testing.T) {
|
||||
db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t)
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
|
||||
thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID})
|
||||
require.NoError(t, err)
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, processed)
|
||||
|
||||
var assistantMsg model.CopilotMessage
|
||||
require.NoError(t, db.Where("copilot_thread_id = ? AND message_type = ?", thread.ID, model.CopilotMessageTypeAssistant).First(&assistantMsg).Error)
|
||||
assert.Equal(t, CopilotUnavailableMessage, assistantMsg.GetMessageContent())
|
||||
}
|
||||
|
||||
func TestCopilotResponseJobForFollowupMessageUsesStoredThreadHistoryAndIdempotency(t *testing.T) {
|
||||
db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t)
|
||||
backend := &fakeCopilotResponseBackend{messages: []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": "Background answer"}}}}
|
||||
svc.SetResponseBackend(backend)
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
|
||||
thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID, ConversationID: 123})
|
||||
require.NoError(t, err)
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, processed)
|
||||
|
||||
result, err := svc.SendMessage(context.Background(), account.ID, user.ID, thread.ID, &SendMessageRequest{Message: "Follow up", ConversationID: 321})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.UserMessage)
|
||||
require.Nil(t, result.AssistantMessage)
|
||||
|
||||
var queued model.BackgroundJob
|
||||
require.NoError(t, db.Where("job_type = ? AND status = ?", TaskTypeCaptainCopilotResponse, model.BackgroundJobStatusQueued).First(&queued).Error)
|
||||
assert.Equal(t, fmt.Sprintf("captain:copilot_response:%d", result.UserMessage.ID), queued.IdempotencyKey)
|
||||
assert.Equal(t, 3, queued.MaxAttempts)
|
||||
var payload captainCopilotResponseJob
|
||||
require.NoError(t, json.Unmarshal(queued.Payload, &payload))
|
||||
assert.Equal(t, account.ID, payload.AccountID)
|
||||
assert.Equal(t, user.ID, payload.UserID)
|
||||
assert.Equal(t, thread.ID, payload.CopilotThreadID)
|
||||
assert.Equal(t, result.UserMessage.ID, payload.MessageID)
|
||||
assert.Equal(t, uint(321), payload.ConversationID)
|
||||
assert.Equal(t, "Follow up", payload.Message)
|
||||
|
||||
processed, err = wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, processed)
|
||||
require.Len(t, backend.requests, 2)
|
||||
secondRequest := backend.requests[1]
|
||||
assert.Equal(t, "Follow up", secondRequest.Message)
|
||||
assert.Equal(t, uint(321), secondRequest.ConversationID)
|
||||
require.NotNil(t, secondRequest.Thread)
|
||||
require.Len(t, secondRequest.Thread.Messages, 3)
|
||||
history := secondRequest.Thread.PreviousHistory(secondRequest.Thread.Messages)
|
||||
require.Len(t, history, 3)
|
||||
assert.Equal(t, "Need help", history[0].Content)
|
||||
assert.Equal(t, "Background answer", history[1].Content)
|
||||
assert.Equal(t, "Follow up", history[2].Content)
|
||||
|
||||
var messages []model.CopilotMessage
|
||||
require.NoError(t, db.Where("copilot_thread_id = ?", thread.ID).Order("id ASC").Find(&messages).Error)
|
||||
require.Len(t, messages, 4)
|
||||
assert.Equal(t, model.CopilotMessageTypeUser, messages[0].MessageType)
|
||||
assert.Equal(t, model.CopilotMessageTypeAssistant, messages[1].MessageType)
|
||||
assert.Equal(t, model.CopilotMessageTypeUser, messages[2].MessageType)
|
||||
assert.Equal(t, model.CopilotMessageTypeAssistant, messages[3].MessageType)
|
||||
assert.Equal(t, "Follow up", messages[2].GetMessageContent())
|
||||
assert.Equal(t, "Background answer", messages[3].GetMessageContent())
|
||||
}
|
||||
|
||||
func TestCopilotResponseJobRetriesBackendFailure(t *testing.T) {
|
||||
db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t)
|
||||
svc.SetResponseBackend(&fakeCopilotResponseBackend{err: errors.New("backend down")})
|
||||
now := time.Date(2026, 6, 6, 4, 0, 0, 0, time.UTC)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute }))
|
||||
svc.SetWorkerPool(wp)
|
||||
|
||||
_, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID})
|
||||
require.NoError(t, err)
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.True(t, processed)
|
||||
|
||||
var job model.BackgroundJob
|
||||
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainCopilotResponse).First(&job).Error)
|
||||
assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status)
|
||||
assert.Contains(t, job.LastError, "backend down")
|
||||
}
|
||||
|
||||
type fakeCopilotResponseBackend struct {
|
||||
messages []CopilotGeneratedMessage
|
||||
err error
|
||||
requests []CopilotResponseRequest
|
||||
}
|
||||
|
||||
func (b *fakeCopilotResponseBackend) GenerateCopilotResponse(ctx context.Context, req CopilotResponseRequest) ([]CopilotGeneratedMessage, error) {
|
||||
b.requests = append(b.requests, req)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
return b.messages, nil
|
||||
}
|
||||
Reference in New Issue
Block a user