diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go index 0bdbf87b..d9890458 100644 --- a/cmd/reindex_search/main.go +++ b/cmd/reindex_search/main.go @@ -37,6 +37,10 @@ func main() { fmt.Fprintf(os.Stderr, "config load: %v\n", err) os.Exit(1) } + if err := validateSearchEngineForReindex(cfg.Search); err != nil { + fmt.Fprintf(os.Stderr, "search config: %v\n", err) + os.Exit(1) + } _ = applogger.Init(applogger.Config{Level: cfg.Log.Level, Format: cfg.Log.Format, Output: "stdout", ErrorOutput: "stderr"}) db, err := app.NewDatabase(&cfg.Database, cfg.Log.Level) @@ -95,6 +99,13 @@ func main() { } } +func validateSearchEngineForReindex(cfg config.SearchConfig) error { + if strings.ToLower(strings.TrimSpace(cfg.Engine)) != search.EngineMeilisearch { + return fmt.Errorf("reindex_search requires search.engine=meilisearch; db fallback does not index documents") + } + return nil +} + func parseTypes(raw string) map[search.SearchResultType]bool { selected := map[search.SearchResultType]bool{} for _, part := range strings.Split(raw, ",") { diff --git a/cmd/reindex_search/main_test.go b/cmd/reindex_search/main_test.go new file mode 100644 index 00000000..3660e829 --- /dev/null +++ b/cmd/reindex_search/main_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/search" +) + +func TestValidateSearchEngineForReindexRequiresMeilisearch(t *testing.T) { + require.NoError(t, validateSearchEngineForReindex(config.SearchConfig{Engine: search.EngineMeilisearch})) + + err := validateSearchEngineForReindex(config.SearchConfig{Engine: search.EngineDB}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires search.engine=meilisearch") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bd351221..fe4e5264 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -190,6 +190,23 @@ func TestValidate_SearchDBFallbackAllowed(t *testing.T) { assert.NoError(t, err) } +func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) { + cfg := &Config{ + Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"}, + Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"}, + Redis: RedisConfig{URL: "redis://localhost:6379"}, + JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, + Log: LogConfig{Level: "info"}, + Worker: WorkerConfig{Concurrency: 1}, + RateLimit: RateLimitConfig{RequestsPerMinute: 100, WindowSeconds: 60}, + Search: SearchConfig{Engine: "db"}, + } + + err := Validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "release mode requires meilisearch") +} + func TestDatabaseConfig_DSN(t *testing.T) { cfg := DatabaseConfig{ Host: "localhost", diff --git a/internal/config/validator.go b/internal/config/validator.go index 2d5d75f1..10b1d54c 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -80,6 +80,9 @@ func Validate(cfg *Config) error { if engine != "meilisearch" && engine != "db" { return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine) } + if engine == "db" && cfg.Server.Mode == "release" { + return fmt.Errorf("search.engine=db is only allowed for local development; release mode requires meilisearch") + } if engine == "meilisearch" { if cfg.Search.Host == "" { return fmt.Errorf("search.host is required when search.engine=meilisearch") diff --git a/internal/search/engine_meili_live_test.go b/internal/search/engine_meili_live_test.go new file mode 100644 index 00000000..4fc739aa --- /dev/null +++ b/internal/search/engine_meili_live_test.go @@ -0,0 +1,113 @@ +package search + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/model" +) + +func TestLiveMeiliSearchEngineIndexesAndSearchesChatwootShapes(t *testing.T) { + host := os.Getenv("GOCHAT_LIVE_MEILI_HOST") + if host == "" { + t.Skip("set GOCHAT_LIVE_MEILI_HOST to run live Meilisearch gate") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + now := time.Now().UTC() + token := fmt.Sprintf("gochat-live-%d", now.UnixNano()) + accountID := uint(990001) + senderID := uint(1234) + prefix := fmt.Sprintf("gochat_live_%d_", now.UnixNano()) + engine := NewMeiliSearchEngine(EngineConfig{ + Host: host, + APIKey: os.Getenv("GOCHAT_LIVE_MEILI_API_KEY"), + IndexPrefix: prefix, + TimeoutSeconds: 5, + }) + defer cleanupLiveIndexes(context.Background(), engine) + + require.NoError(t, engine.Bootstrap(ctx)) + waitForLiveIndex(t, ctx, engine, ResultTypeMessage) + waitForLiveIndex(t, ctx, engine, ResultTypeContact) + + require.NoError(t, engine.IndexBatch(ctx, []SearchDocument{ + MessageDocument(model.Message{ + Base: model.Base{ID: 7001, CreatedAt: now, UpdatedAt: now}, + AccountID: accountID, + InboxID: 77, + ConversationID: 88, + SenderType: "contact", + SenderID: &senderID, + MessageType: "incoming", + ContentType: "text", + Status: "sent", + Content: token + " message body", + }), + ContactDocument(model.Contact{ + Base: model.Base{ID: 7002, CreatedAt: now, UpdatedAt: now}, + AccountID: accountID, + Name: token + " Contact", + Email: token + "@example.com", + }), + })) + + messageFilter := &SearchFilter{Page: 1, PerPage: 15, Types: []SearchResultType{ResultTypeMessage}, SenderType: "contact", SenderID: &senderID} + messageResp := waitForLiveSearchResult(t, ctx, engine, accountID, token, messageFilter, ResultTypeMessage) + require.Equal(t, int64(1), messageResp.ByType[string(ResultTypeMessage)]) + require.Equal(t, uint(7001), messageResp.Results[0].ID) + require.Equal(t, accountID, messageResp.Results[0].AccountID) + + contactFilter := &SearchFilter{Page: 1, PerPage: 15, Types: []SearchResultType{ResultTypeContact}} + contactResp := waitForLiveSearchResult(t, ctx, engine, accountID, token, contactFilter, ResultTypeContact) + require.Equal(t, int64(1), contactResp.ByType[string(ResultTypeContact)]) + require.Equal(t, uint(7002), contactResp.Results[0].ID) + require.Equal(t, accountID, contactResp.Results[0].AccountID) +} + +func waitForLiveIndex(t *testing.T, ctx context.Context, engine *MeiliSearchEngine, docType SearchResultType) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + resp, err := engine.client.R().SetContext(ctx).Get(fmt.Sprintf("/indexes/%s", engine.indexName(docType))) + if err == nil && resp.StatusCode() == 200 { + return + } + if time.Now().After(deadline) { + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode()) + } + time.Sleep(200 * time.Millisecond) + } +} + +func waitForLiveSearchResult(t *testing.T, ctx context.Context, engine *MeiliSearchEngine, accountID uint, query string, filter *SearchFilter, resultType SearchResultType) *SearchResponse { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + var lastErr error + for { + resp, err := engine.Search(ctx, accountID, query, filter) + if err == nil && len(resp.Results) > 0 && resp.Results[0].Type == resultType { + return resp + } + lastErr = err + if time.Now().After(deadline) { + require.NoError(t, lastErr) + require.FailNow(t, "timed out waiting for live Meilisearch result") + } + time.Sleep(250 * time.Millisecond) + } +} + +func cleanupLiveIndexes(ctx context.Context, engine *MeiliSearchEngine) { + for _, docType := range searchableTypes(nil) { + _, _ = engine.client.R().SetContext(ctx).Delete(fmt.Sprintf("/indexes/%s", engine.indexName(docType))) + } +}