Monorepo for Tangled
1package issues_indexer
2
3import (
4 "context"
5 "os"
6 "testing"
7
8 "github.com/blevesearch/bleve/v2"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 "tangled.org/core/appview/models"
12 "tangled.org/core/appview/pagination"
13 "tangled.org/core/appview/searchquery"
14)
15
16func setupTestIndexer(t *testing.T) (*Indexer, func()) {
17 t.Helper()
18
19 tmpDir, err := os.MkdirTemp("", "issue_indexer_test")
20 require.NoError(t, err)
21
22 ix := NewIndexer(tmpDir)
23
24 mapping, err := generateIssueIndexMapping()
25 require.NoError(t, err)
26
27 indexer, err := bleve.New(tmpDir, mapping)
28 require.NoError(t, err)
29 ix.indexer = indexer
30
31 cleanup := func() {
32 ix.indexer.Close()
33 os.RemoveAll(tmpDir)
34 }
35
36 return ix, cleanup
37}
38
39func boolPtr(b bool) *bool { return &b }
40
41func makeLabelState(labels ...string) models.LabelState {
42 state := models.NewLabelState()
43 for _, label := range labels {
44 state.Inner()[label] = make(map[string]struct{})
45 state.SetName(label, label)
46 }
47 return state
48}
49
50func TestSearchFilters(t *testing.T) {
51 ix, cleanup := setupTestIndexer(t)
52 defer cleanup()
53
54 ctx := context.Background()
55
56 err := ix.Index(ctx,
57 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")},
58 models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")},
59 models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login timeout", Body: "Login takes too long", Open: false, Did: "did:plc:alice", Labels: makeLabelState("bug")},
60 )
61 require.NoError(t, err)
62
63 opts := func() models.IssueSearchOptions {
64 return models.IssueSearchOptions{
65 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
66 IsOpen: boolPtr(true),
67 Page: pagination.Page{Limit: 10},
68 }
69 }
70
71 // Keyword in title
72 o := opts()
73 o.Keywords = []string{"bug"}
74 result, err := ix.Search(ctx, o)
75 require.NoError(t, err)
76 assert.Equal(t, uint64(1), result.Total)
77 assert.Contains(t, result.Hits, int64(1))
78
79 // Keyword in body
80 o = opts()
81 o.Keywords = []string{"theme"}
82 result, err = ix.Search(ctx, o)
83 require.NoError(t, err)
84 assert.Equal(t, uint64(1), result.Total)
85 assert.Contains(t, result.Hits, int64(2))
86
87 // Phrase match
88 o = opts()
89 o.Phrases = []string{"login bug"}
90 result, err = ix.Search(ctx, o)
91 require.NoError(t, err)
92 assert.Equal(t, uint64(1), result.Total)
93 assert.Contains(t, result.Hits, int64(1))
94
95 // Author filter
96 o = opts()
97 o.AuthorDid = "did:plc:alice"
98 result, err = ix.Search(ctx, o)
99 require.NoError(t, err)
100 assert.Equal(t, uint64(1), result.Total)
101 assert.Contains(t, result.Hits, int64(1))
102
103 // Label filter
104 o = opts()
105 o.Labels = []string{"bug"}
106 result, err = ix.Search(ctx, o)
107 require.NoError(t, err)
108 assert.Equal(t, uint64(1), result.Total)
109 assert.Contains(t, result.Hits, int64(1))
110
111 // State filter (closed)
112 o = opts()
113 o.IsOpen = boolPtr(false)
114 o.Labels = []string{"bug"}
115 result, err = ix.Search(ctx, o)
116 require.NoError(t, err)
117 assert.Equal(t, uint64(1), result.Total)
118 assert.Contains(t, result.Hits, int64(3))
119
120 // Combined: keyword + author + label
121 o = opts()
122 o.Keywords = []string{"login"}
123 o.AuthorDid = "did:plc:alice"
124 o.Labels = []string{"bug"}
125 result, err = ix.Search(ctx, o)
126 require.NoError(t, err)
127 assert.Equal(t, uint64(1), result.Total)
128 assert.Contains(t, result.Hits, int64(1))
129}
130
131func TestSearchLabelAND(t *testing.T) {
132 ix, cleanup := setupTestIndexer(t)
133 defer cleanup()
134
135 ctx := context.Background()
136
137 err := ix.Index(ctx,
138 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 1", Body: "Body", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")},
139 models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 2", Body: "Body", Open: true, Did: "did:plc:bob", Labels: makeLabelState("bug", "urgent")},
140 )
141 require.NoError(t, err)
142
143 result, err := ix.Search(ctx, models.IssueSearchOptions{
144 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
145 IsOpen: boolPtr(true),
146 Labels: []string{"bug", "urgent"},
147 Page: pagination.Page{Limit: 10},
148 })
149 require.NoError(t, err)
150 assert.Equal(t, uint64(1), result.Total)
151 assert.Contains(t, result.Hits, int64(2))
152}
153
154func TestSearchNegation(t *testing.T) {
155 ix, cleanup := setupTestIndexer(t)
156 defer cleanup()
157
158 ctx := context.Background()
159
160 err := ix.Index(ctx,
161 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")},
162 models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")},
163 models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug", "urgent")},
164 )
165 require.NoError(t, err)
166
167 opts := func() models.IssueSearchOptions {
168 return models.IssueSearchOptions{
169 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
170 IsOpen: boolPtr(true),
171 Page: pagination.Page{Limit: 10},
172 }
173 }
174
175 // Negated label: exclude "bug", should only return issue 2
176 o := opts()
177 o.NegatedLabels = []string{"bug"}
178 result, err := ix.Search(ctx, o)
179 require.NoError(t, err)
180 assert.Equal(t, uint64(1), result.Total)
181 assert.Contains(t, result.Hits, int64(2))
182
183 // Negated keyword: exclude "login", should return issues 2 and 3
184 o = opts()
185 o.NegatedKeywords = []string{"login"}
186 result, err = ix.Search(ctx, o)
187 require.NoError(t, err)
188 assert.Equal(t, uint64(2), result.Total)
189 assert.Contains(t, result.Hits, int64(2))
190 assert.Contains(t, result.Hits, int64(3))
191
192 // Positive label + negated label: bug but not urgent
193 o = opts()
194 o.Labels = []string{"bug"}
195 o.NegatedLabels = []string{"urgent"}
196 result, err = ix.Search(ctx, o)
197 require.NoError(t, err)
198 assert.Equal(t, uint64(1), result.Total)
199 assert.Contains(t, result.Hits, int64(1))
200
201 // Negated phrase
202 o = opts()
203 o.NegatedPhrases = []string{"dark theme"}
204 result, err = ix.Search(ctx, o)
205 require.NoError(t, err)
206 assert.Equal(t, uint64(2), result.Total)
207 assert.NotContains(t, result.Hits, int64(2))
208}
209
210func TestSearchNegatedPhraseParsed(t *testing.T) {
211 ix, cleanup := setupTestIndexer(t)
212 defer cleanup()
213
214 ctx := context.Background()
215
216 err := ix.Index(ctx,
217 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice"},
218 models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob"},
219 models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice"},
220 )
221 require.NoError(t, err)
222
223 // Parse a query with a negated quoted phrase, as the handler would
224 query := searchquery.Parse(`-"dark theme"`)
225 var negatedPhrases []string
226 for _, item := range query.Items() {
227 if item.Kind == searchquery.KindQuoted && item.Negated {
228 negatedPhrases = append(negatedPhrases, item.Value)
229 }
230 }
231 require.Equal(t, []string{"dark theme"}, negatedPhrases)
232
233 result, err := ix.Search(ctx, models.IssueSearchOptions{
234 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
235 IsOpen: boolPtr(true),
236 NegatedPhrases: negatedPhrases,
237 Page: pagination.Page{Limit: 10},
238 })
239 require.NoError(t, err)
240 assert.Equal(t, uint64(2), result.Total)
241 assert.NotContains(t, result.Hits, int64(2))
242}
243
244func TestSearchNoResults(t *testing.T) {
245 ix, cleanup := setupTestIndexer(t)
246 defer cleanup()
247
248 ctx := context.Background()
249
250 err := ix.Index(ctx,
251 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue", Body: "Body", Open: true, Did: "did:plc:alice"},
252 )
253 require.NoError(t, err)
254
255 result, err := ix.Search(ctx, models.IssueSearchOptions{
256 Keywords: []string{"nonexistent"},
257 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
258 IsOpen: boolPtr(true),
259 Page: pagination.Page{Limit: 10},
260 })
261 require.NoError(t, err)
262 assert.Equal(t, uint64(0), result.Total)
263 assert.Empty(t, result.Hits)
264}