From 3a2cc5e242344d41715c9024a2d5e5251b8cd0e0 Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Mon, 16 Mar 2026 15:35:02 -0400 Subject: [PATCH] ES search: include footer and author name in indexed fields. (#35603) * Include footer and author name in indexed fields. --- .../enterprise/elasticsearch/common/common.go | 4 +- .../elasticsearch/common/common_test.go | 174 ++++++++++++++++++ 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/server/enterprise/elasticsearch/common/common.go b/server/enterprise/elasticsearch/common/common.go index 255d366ee1b..0ec56b0e9ca 100644 --- a/server/enterprise/elasticsearch/common/common.go +++ b/server/enterprise/elasticsearch/common/common.go @@ -133,7 +133,7 @@ func ESPostFromPostForIndexing(post *model.PostForIndexing) *ESPost { if !mOk { continue } - for _, key := range []string{"text", "title", "pretext", "fallback"} { + for _, key := range []string{"text", "title", "pretext", "fallback", "footer", "author_name"} { if s, _ := m[key].(string); s != "" { searchAttachments = append(searchAttachments, s) } @@ -165,7 +165,7 @@ func ESPostFromPostForIndexing(post *model.PostForIndexing) *ESPost { if attachment == nil { continue } - for _, s := range []string{attachment.Text, attachment.Title, attachment.Pretext, attachment.Fallback} { + for _, s := range []string{attachment.Text, attachment.Title, attachment.Pretext, attachment.Fallback, attachment.Footer, attachment.AuthorName} { if s != "" { searchAttachments = append(searchAttachments, s) } diff --git a/server/enterprise/elasticsearch/common/common_test.go b/server/enterprise/elasticsearch/common/common_test.go index 3715a4520b8..b112ce7de70 100644 --- a/server/enterprise/elasticsearch/common/common_test.go +++ b/server/enterprise/elasticsearch/common/common_test.go @@ -4,6 +4,7 @@ package common import ( + "encoding/json" "testing" "time" @@ -319,6 +320,64 @@ func TestESPostFromPostForIndexing(t *testing.T) { assert.Contains(t, espost.Attachments, "99.5") }) + t.Run("any form indexes footer and author_name", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []any{ + map[string]any{ + "text": "body text", + "footer": "Opportunity #OPP-000035341 • United States", + "author_name": "Salesforce", + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "body text") + assert.Contains(t, espost.Attachments, "Opportunity #OPP-000035341 • United States") + assert.Contains(t, espost.Attachments, "Salesforce") + }) + + t.Run("MessageAttachment form indexes footer and author_name", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []*model.MessageAttachment{ + { + Text: "body text", + Footer: "Opportunity #OPP-000035341 • United States", + AuthorName: "Salesforce", + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "body text") + assert.Contains(t, espost.Attachments, "Opportunity #OPP-000035341 • United States") + assert.Contains(t, espost.Attachments, "Salesforce") + }) + t.Run("multiple attachments are combined", func(t *testing.T) { post := model.PostForIndexing{ TeamId: model.NewId(), @@ -353,6 +412,121 @@ func TestESPostFromPostForIndexing(t *testing.T) { }) } +// TestESPostFromPost_CreatePostJSONRoundTrip simulates the exact flow that happens +// in production: App.CreatePost converts []*MessageAttachment to []any via JSON +// marshal/unmarshal (Post.Attachments in post.go), then the search layer calls +// ESPostFromPost which does ShallowCopy → ESPostFromPostForIndexing. +func TestESPostFromPost_CreatePostJSONRoundTrip(t *testing.T) { + // Step 1: Start with typed MessageAttachment (as a plugin/webhook would create) + original := &model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "#closedwon #renewal", + Type: "slack_attachment", + } + original.AddProp(model.PostPropsAttachments, []*model.MessageAttachment{ + { + Title: "Account: Acme Corp / Widget Industries, LLC", + Text: "Renewal ARR: $49,140.00", + Pretext: "#closedwon #renewal", + Footer: "Opportunity #OPP-000035341 • United States", + AuthorName: "CRM Bot", + Fields: []*model.MessageAttachmentField{ + {Title: "Sales Rep", Value: "Jane Smith"}, + {Title: "Account Manager", Value: "John Doe"}, + {Title: "Opportunity", Value: "Acme Corp / Widget Industries, LLC - Enterprise - 350 Seats - '26 Renewal"}, + {Title: "Seats", Value: 350.0}, + }, + }, + }) + + // Step 2: Simulate CreatePost JSON round-trip (post.go:305-315) + if attachments, ok := original.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment); ok { + jsonAttachments, err := json.Marshal(attachments) + require.NoError(t, err) + attachmentsInterface := []any{} + err = json.Unmarshal(jsonAttachments, &attachmentsInterface) + require.NoError(t, err) + original.AddProp(model.PostPropsAttachments, attachmentsInterface) + } else { + require.Fail(t, "expected []*MessageAttachment type assertion to succeed") + } + + // Step 3: Simulate ESPostFromPost (what the search layer calls) + teamId := model.NewId() + esPost, err := ESPostFromPost(original, teamId, "O") + require.NoError(t, err) + + // Step 4: Verify all attachment fields are indexed + assert.Contains(t, esPost.Attachments, "Account: Acme Corp / Widget Industries, LLC", "title should be indexed") + assert.Contains(t, esPost.Attachments, "Renewal ARR: $49,140.00", "text should be indexed") + assert.Contains(t, esPost.Attachments, "#closedwon #renewal", "pretext should be indexed") + + // Field titles and values + assert.Contains(t, esPost.Attachments, "Sales Rep", "field title should be indexed") + assert.Contains(t, esPost.Attachments, "Jane Smith", "field value should be indexed") + assert.Contains(t, esPost.Attachments, "Account Manager", "field title should be indexed") + assert.Contains(t, esPost.Attachments, "John Doe", "field value should be indexed") + assert.Contains(t, esPost.Attachments, "Opportunity", "field title should be indexed") + assert.Contains(t, esPost.Attachments, "Acme Corp / Widget Industries, LLC - Enterprise - 350 Seats - '26 Renewal", "field value should be indexed") + assert.Contains(t, esPost.Attachments, "350", "numeric field value should be indexed") + + // Footer and AuthorName + assert.Contains(t, esPost.Attachments, "Opportunity #OPP-000035341 • United States", "footer should be indexed") + assert.Contains(t, esPost.Attachments, "CRM Bot", "author_name should be indexed") + + t.Logf("Attachments field content: %s", esPost.Attachments) +} + +// TestESPostFromPost_APIJSONUnmarshal simulates a post created via the REST API +// where Props are deserialized from JSON (attachments arrive as []any directly). +func TestESPostFromPost_APIJSONUnmarshal(t *testing.T) { + // Simulate what happens when a bot POSTs JSON to /api/v4/posts + postJSON := `{ + "channel_id": "` + model.NewId() + `", + "message": "", + "props": { + "attachments": [{ + "title": "Account: Acme Corp", + "text": "Renewal ARR: $49,140.00", + "footer": "Opportunity #OPP-000035341", + "author_name": "CRM Bot", + "fields": [ + {"title": "Sales Rep", "value": "Jane Smith"}, + {"title": "Seats", "value": 350} + ] + }] + } + }` + + var post model.Post + err := json.Unmarshal([]byte(postJSON), &post) + require.NoError(t, err) + post.Id = model.NewId() + post.UserId = model.NewId() + post.CreateAt = model.GetMillis() + + // The CreatePost conversion at post.go:305-315 would NOT trigger here + // because the type is already []any, not []*MessageAttachment + _, typeOk := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) + assert.False(t, typeOk, "API-created post should have []any, not []*MessageAttachment") + + esPost, err := ESPostFromPost(&post, model.NewId(), "O") + require.NoError(t, err) + + assert.Contains(t, esPost.Attachments, "Account: Acme Corp", "title should be indexed") + assert.Contains(t, esPost.Attachments, "Renewal ARR: $49,140.00", "text should be indexed") + assert.Contains(t, esPost.Attachments, "Sales Rep", "field title should be indexed") + assert.Contains(t, esPost.Attachments, "Jane Smith", "field value should be indexed") + assert.Contains(t, esPost.Attachments, "350", "numeric field value should be indexed") + assert.Contains(t, esPost.Attachments, "Opportunity #OPP-000035341", "footer should be indexed") + assert.Contains(t, esPost.Attachments, "CRM Bot", "author_name should be indexed") + + t.Logf("Attachments field content: %s", esPost.Attachments) +} + func TestGetMatchesForHit(t *testing.T) { snippets := map[string][]string{ "message": {