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.
248 lines
9.6 KiB
Go
248 lines
9.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
)
|
|
|
|
type recordingDurableSearchIndexer struct {
|
|
indexedConversations []model.Conversation
|
|
indexedMessages []model.Message
|
|
indexedContacts []uint
|
|
indexedContactLabels map[uint][]string
|
|
indexedArticles []model.Article
|
|
deletedContacts []uint
|
|
err error
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
|
|
if conversation != nil {
|
|
r.indexedConversations = append(r.indexedConversations, *conversation)
|
|
}
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) DeleteConversation(ctx context.Context, accountID uint, id uint) error {
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error {
|
|
if message != nil {
|
|
r.indexedMessages = append(r.indexedMessages, *message)
|
|
}
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) DeleteMessage(ctx context.Context, accountID uint, id uint) error {
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error {
|
|
r.indexedContacts = append(r.indexedContacts, contact.ID)
|
|
if r.indexedContactLabels == nil {
|
|
r.indexedContactLabels = map[uint][]string{}
|
|
}
|
|
r.indexedContactLabels[contact.ID] = append([]string(nil), contact.Labels...)
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) DeleteContact(ctx context.Context, accountID uint, id uint) error {
|
|
r.deletedContacts = append(r.deletedContacts, id)
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) IndexCompany(ctx context.Context, company *model.Company) error {
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) DeleteCompany(ctx context.Context, accountID uint, id uint) error {
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error {
|
|
if article != nil {
|
|
r.indexedArticles = append(r.indexedArticles, *article)
|
|
}
|
|
return r.err
|
|
}
|
|
|
|
func (r *recordingDurableSearchIndexer) DeleteArticle(ctx context.Context, accountID uint, id uint) error {
|
|
return r.err
|
|
}
|
|
|
|
func TestDurableSearchIndexerQueuesAndReplaysContactIndex(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Index Me", Email: "index@example.com"}
|
|
if err := db.Create(contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 0, 0, 0, time.UTC) }))
|
|
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
|
|
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
|
t.Fatalf("queue contact index: %v", err)
|
|
}
|
|
if len(delegate.indexedContacts) != 0 {
|
|
t.Fatalf("contact indexed synchronously before worker replay")
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ? AND status = ?", TaskTypeSearchIndex, "search", model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
|
|
t.Fatalf("count search jobs: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected one queued search job, got %d", count)
|
|
}
|
|
processed, err := wp.ProcessOne(context.Background())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process search job: processed=%v err=%v", processed, err)
|
|
}
|
|
if len(delegate.indexedContacts) != 1 || delegate.indexedContacts[0] != contact.ID {
|
|
t.Fatalf("expected durable contact index replay, got %#v", delegate.indexedContacts)
|
|
}
|
|
}
|
|
|
|
func TestDurableSearchIndexerPreloadsContactLabels(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Label Me", Email: "label@example.com"}
|
|
if err := db.Create(contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
tag := model.Tag{AccountID: account.ID, Name: "vip"}
|
|
if err := db.Create(&tag).Error; err != nil {
|
|
t.Fatalf("create tag: %v", err)
|
|
}
|
|
if err := db.Create(&model.ContactLabel{AccountID: account.ID, ContactID: contact.ID, TagID: tag.ID}).Error; err != nil {
|
|
t.Fatalf("create contact label: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
indexer := NewDurableSearchIndexer(db, nil, delegate)
|
|
|
|
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
|
t.Fatalf("index contact: %v", err)
|
|
}
|
|
|
|
labels := delegate.indexedContactLabels[contact.ID]
|
|
if len(labels) != 1 || labels[0] != "vip" {
|
|
t.Fatalf("expected preloaded contact labels, got %#v", labels)
|
|
}
|
|
}
|
|
|
|
func TestDurableSearchIndexerPreloadsConversationContact(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Ada Lovelace", Email: "ada@example.com"}
|
|
if err := db.Create(contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
conversation := &model.Conversation{AccountID: account.ID, ContactID: contact.ID, InboxID: 1, ChannelType: "web_widget", Channel: "web_widget", Status: "open"}
|
|
if err := db.Create(conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
indexer := NewDurableSearchIndexer(db, nil, delegate)
|
|
|
|
if err := indexer.IndexConversation(context.Background(), conversation); err != nil {
|
|
t.Fatalf("index conversation: %v", err)
|
|
}
|
|
|
|
if len(delegate.indexedConversations) != 1 {
|
|
t.Fatalf("expected one indexed conversation, got %#v", delegate.indexedConversations)
|
|
}
|
|
indexed := delegate.indexedConversations[0]
|
|
if indexed.Contact == nil || indexed.Contact.Name != "Ada Lovelace" || indexed.Contact.Email != "ada@example.com" {
|
|
t.Fatalf("expected preloaded contact in indexed conversation, got %#v", indexed.Contact)
|
|
}
|
|
}
|
|
|
|
func TestDurableSearchIndexerPreloadsArticlePortalAndCategory(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
portal := &model.Portal{AccountID: account.ID, Name: "Help Center", Slug: "help-center", Locale: "en"}
|
|
if err := db.Create(portal).Error; err != nil {
|
|
t.Fatalf("create portal: %v", err)
|
|
}
|
|
category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"}
|
|
if err := db.Create(category).Error; err != nil {
|
|
t.Fatalf("create category: %v", err)
|
|
}
|
|
article := &model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing FAQ", Slug: "billing-faq", Locale: "en", Status: "published"}
|
|
if err := db.Create(article).Error; err != nil {
|
|
t.Fatalf("create article: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
indexer := NewDurableSearchIndexer(db, nil, delegate)
|
|
|
|
if err := indexer.IndexArticle(context.Background(), article); err != nil {
|
|
t.Fatalf("index article: %v", err)
|
|
}
|
|
|
|
if len(delegate.indexedArticles) != 1 {
|
|
t.Fatalf("expected one indexed article, got %#v", delegate.indexedArticles)
|
|
}
|
|
indexed := delegate.indexedArticles[0]
|
|
if indexed.Portal.Slug != "help-center" || indexed.Category == nil || indexed.Category.Name != "Billing" {
|
|
t.Fatalf("expected preloaded portal/category in indexed article, got portal=%#v category=%#v", indexed.Portal, indexed.Category)
|
|
}
|
|
}
|
|
|
|
func TestDurableSearchIndexerRetriesDelegateFailure(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Retry Me"}
|
|
if err := db.Create(contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{err: errors.New("meili unavailable")}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 15, 0, 0, time.UTC) }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute }))
|
|
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
|
|
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
|
t.Fatalf("queue contact index: %v", err)
|
|
}
|
|
processed, err := wp.ProcessOne(context.Background())
|
|
if err == nil || !processed {
|
|
t.Fatalf("expected retryable worker error, processed=%v err=%v", processed, err)
|
|
}
|
|
var job model.BackgroundJob
|
|
if err := db.Where("job_type = ?", TaskTypeSearchIndex).First(&job).Error; err != nil {
|
|
t.Fatalf("load search job: %v", err)
|
|
}
|
|
if job.Status != model.BackgroundJobStatusRetrying || job.LastError == "" {
|
|
t.Fatalf("expected retrying search job with error, got status=%s last_error=%q", job.Status, job.LastError)
|
|
}
|
|
}
|
|
|
|
func TestDurableSearchIndexerDeletesWhenIndexedRecordDisappears(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
account := createTestAccount(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Delete Before Replay"}
|
|
if err := db.Create(contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 14, 30, 0, 0, time.UTC) }))
|
|
indexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
|
|
if err := indexer.IndexContact(context.Background(), contact); err != nil {
|
|
t.Fatalf("queue contact index: %v", err)
|
|
}
|
|
if err := db.Delete(contact).Error; err != nil {
|
|
t.Fatalf("delete contact before replay: %v", err)
|
|
}
|
|
processed, err := wp.ProcessOne(context.Background())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process search job: processed=%v err=%v", processed, err)
|
|
}
|
|
if len(delegate.deletedContacts) != 1 || delegate.deletedContacts[0] != contact.ID {
|
|
t.Fatalf("expected missing record to delete search document, got %#v", delegate.deletedContacts)
|
|
}
|
|
}
|