Monorepo for Tangled
1package issues_indexer
2
3import (
4 "context"
5 "os"
6 "strings"
7 "testing"
8
9 "github.com/blevesearch/bleve/v2"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 "tangled.org/core/appview/models"
13 "tangled.org/core/appview/pagination"
14 "tangled.org/core/appview/searchquery"
15)
16
17func setupTestIndexer(t *testing.T) (*Indexer, func()) {
18 t.Helper()
19
20 tmpDir, err := os.MkdirTemp("", "issue_indexer_test")
21 require.NoError(t, err)
22
23 ix := NewIndexer(tmpDir)
24
25 mapping, err := generateIssueIndexMapping()
26 require.NoError(t, err)
27
28 indexer, err := bleve.New(tmpDir, mapping)
29 require.NoError(t, err)
30 ix.indexer = indexer
31
32 cleanup := func() {
33 ix.indexer.Close()
34 os.RemoveAll(tmpDir)
35 }
36
37 return ix, cleanup
38}
39
40func boolPtr(b bool) *bool { return &b }
41
42// makeLabelState creates a LabelState for testing. Each entry is either
43// "name" (null-type label) or "name=val" (valued label).
44func makeLabelState(entries ...string) models.LabelState {
45 state := models.NewLabelState()
46 for _, entry := range entries {
47 if eqIdx := strings.Index(entry, "="); eqIdx > 0 {
48 name := entry[:eqIdx]
49 val := entry[eqIdx+1:]
50 if state.Inner()[name] == nil {
51 state.Inner()[name] = make(map[string]struct{})
52 }
53 state.Inner()[name][val] = struct{}{}
54 state.SetName(name, name)
55 } else {
56 state.Inner()[entry] = make(map[string]struct{})
57 state.SetName(entry, entry)
58 }
59 }
60 return state
61}
62
63func TestSearchFilters(t *testing.T) {
64 ix, cleanup := setupTestIndexer(t)
65 defer cleanup()
66
67 ctx := context.Background()
68
69 err := ix.Index(ctx,
70 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")},
71 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")},
72 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")},
73 )
74 require.NoError(t, err)
75
76 opts := func() models.IssueSearchOptions {
77 return models.IssueSearchOptions{
78 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
79 IsOpen: boolPtr(true),
80 Page: pagination.Page{Limit: 10},
81 }
82 }
83
84 // Keyword in title
85 o := opts()
86 o.Keywords = []string{"bug"}
87 result, err := ix.Search(ctx, o)
88 require.NoError(t, err)
89 assert.Equal(t, uint64(1), result.Total)
90 assert.Contains(t, result.Hits, int64(1))
91
92 // Keyword in body
93 o = opts()
94 o.Keywords = []string{"theme"}
95 result, err = ix.Search(ctx, o)
96 require.NoError(t, err)
97 assert.Equal(t, uint64(1), result.Total)
98 assert.Contains(t, result.Hits, int64(2))
99
100 // Phrase match
101 o = opts()
102 o.Phrases = []string{"login bug"}
103 result, err = ix.Search(ctx, o)
104 require.NoError(t, err)
105 assert.Equal(t, uint64(1), result.Total)
106 assert.Contains(t, result.Hits, int64(1))
107
108 // Author filter
109 o = opts()
110 o.AuthorDid = "did:plc:alice"
111 result, err = ix.Search(ctx, o)
112 require.NoError(t, err)
113 assert.Equal(t, uint64(1), result.Total)
114 assert.Contains(t, result.Hits, int64(1))
115
116 // Label filter
117 o = opts()
118 o.Labels = []string{"bug"}
119 result, err = ix.Search(ctx, o)
120 require.NoError(t, err)
121 assert.Equal(t, uint64(1), result.Total)
122 assert.Contains(t, result.Hits, int64(1))
123
124 // State filter (closed)
125 o = opts()
126 o.IsOpen = boolPtr(false)
127 o.Labels = []string{"bug"}
128 result, err = ix.Search(ctx, o)
129 require.NoError(t, err)
130 assert.Equal(t, uint64(1), result.Total)
131 assert.Contains(t, result.Hits, int64(3))
132
133 // Combined: keyword + author + label
134 o = opts()
135 o.Keywords = []string{"login"}
136 o.AuthorDid = "did:plc:alice"
137 o.Labels = []string{"bug"}
138 result, err = ix.Search(ctx, o)
139 require.NoError(t, err)
140 assert.Equal(t, uint64(1), result.Total)
141 assert.Contains(t, result.Hits, int64(1))
142}
143
144func TestSearchLabelAND(t *testing.T) {
145 ix, cleanup := setupTestIndexer(t)
146 defer cleanup()
147
148 ctx := context.Background()
149
150 err := ix.Index(ctx,
151 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")},
152 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")},
153 )
154 require.NoError(t, err)
155
156 result, err := ix.Search(ctx, models.IssueSearchOptions{
157 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
158 IsOpen: boolPtr(true),
159 Labels: []string{"bug", "urgent"},
160 Page: pagination.Page{Limit: 10},
161 })
162 require.NoError(t, err)
163 assert.Equal(t, uint64(1), result.Total)
164 assert.Contains(t, result.Hits, int64(2))
165}
166
167func TestSearchNegation(t *testing.T) {
168 ix, cleanup := setupTestIndexer(t)
169 defer cleanup()
170
171 ctx := context.Background()
172
173 err := ix.Index(ctx,
174 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")},
175 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")},
176 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")},
177 )
178 require.NoError(t, err)
179
180 opts := func() models.IssueSearchOptions {
181 return models.IssueSearchOptions{
182 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
183 IsOpen: boolPtr(true),
184 Page: pagination.Page{Limit: 10},
185 }
186 }
187
188 // Negated label: exclude "bug", should only return issue 2
189 o := opts()
190 o.NegatedLabels = []string{"bug"}
191 result, err := ix.Search(ctx, o)
192 require.NoError(t, err)
193 assert.Equal(t, uint64(1), result.Total)
194 assert.Contains(t, result.Hits, int64(2))
195
196 // Negated keyword: exclude "login", should return issues 2 and 3
197 o = opts()
198 o.NegatedKeywords = []string{"login"}
199 result, err = ix.Search(ctx, o)
200 require.NoError(t, err)
201 assert.Equal(t, uint64(2), result.Total)
202 assert.Contains(t, result.Hits, int64(2))
203 assert.Contains(t, result.Hits, int64(3))
204
205 // Positive label + negated label: bug but not urgent
206 o = opts()
207 o.Labels = []string{"bug"}
208 o.NegatedLabels = []string{"urgent"}
209 result, err = ix.Search(ctx, o)
210 require.NoError(t, err)
211 assert.Equal(t, uint64(1), result.Total)
212 assert.Contains(t, result.Hits, int64(1))
213
214 // Negated phrase
215 o = opts()
216 o.NegatedPhrases = []string{"dark theme"}
217 result, err = ix.Search(ctx, o)
218 require.NoError(t, err)
219 assert.Equal(t, uint64(2), result.Total)
220 assert.NotContains(t, result.Hits, int64(2))
221}
222
223func TestSearchNegatedPhraseParsed(t *testing.T) {
224 ix, cleanup := setupTestIndexer(t)
225 defer cleanup()
226
227 ctx := context.Background()
228
229 err := ix.Index(ctx,
230 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"},
231 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"},
232 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"},
233 )
234 require.NoError(t, err)
235
236 // Parse a query with a negated quoted phrase, as the handler would
237 query := searchquery.Parse(`-"dark theme"`)
238 var negatedPhrases []string
239 for _, item := range query.Items() {
240 if item.Kind == searchquery.KindQuoted && item.Negated {
241 negatedPhrases = append(negatedPhrases, item.Value)
242 }
243 }
244 require.Equal(t, []string{"dark theme"}, negatedPhrases)
245
246 result, err := ix.Search(ctx, models.IssueSearchOptions{
247 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
248 IsOpen: boolPtr(true),
249 NegatedPhrases: negatedPhrases,
250 Page: pagination.Page{Limit: 10},
251 })
252 require.NoError(t, err)
253 assert.Equal(t, uint64(2), result.Total)
254 assert.NotContains(t, result.Hits, int64(2))
255}
256
257func TestSearchNoResults(t *testing.T) {
258 ix, cleanup := setupTestIndexer(t)
259 defer cleanup()
260
261 ctx := context.Background()
262
263 err := ix.Index(ctx,
264 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue", Body: "Body", Open: true, Did: "did:plc:alice"},
265 )
266 require.NoError(t, err)
267
268 result, err := ix.Search(ctx, models.IssueSearchOptions{
269 Keywords: []string{"nonexistent"},
270 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
271 IsOpen: boolPtr(true),
272 Page: pagination.Page{Limit: 10},
273 })
274 require.NoError(t, err)
275 assert.Equal(t, uint64(0), result.Total)
276 assert.Empty(t, result.Hits)
277}
278
279func TestSearchLabelValues(t *testing.T) {
280 ix, cleanup := setupTestIndexer(t)
281 defer cleanup()
282
283 ctx := context.Background()
284
285 err := ix.Index(ctx,
286 models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority bug", Body: "Urgent", Open: true, Did: "did:plc:alice",
287 Labels: makeLabelState("bug", "priority=high")},
288 models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Low priority feature", Body: "Nice to have", Open: true, Did: "did:plc:bob",
289 Labels: makeLabelState("feature", "priority=low")},
290 models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority feature", Body: "Important", Open: true, Did: "did:plc:alice",
291 Labels: makeLabelState("feature", "priority=high")},
292 )
293 require.NoError(t, err)
294
295 opts := func() models.IssueSearchOptions {
296 return models.IssueSearchOptions{
297 RepoAt: "at://did:plc:test/sh.tangled.repo/abc",
298 IsOpen: boolPtr(true),
299 Page: pagination.Page{Limit: 10},
300 }
301 }
302
303 o := opts()
304 o.LabelValues = []string{"priority:high"}
305 result, err := ix.Search(ctx, o)
306 require.NoError(t, err)
307 assert.Equal(t, uint64(2), result.Total)
308 assert.Contains(t, result.Hits, int64(1))
309 assert.Contains(t, result.Hits, int64(3))
310
311 o = opts()
312 o.LabelValues = []string{"priority:low"}
313 result, err = ix.Search(ctx, o)
314 require.NoError(t, err)
315 assert.Equal(t, uint64(1), result.Total)
316 assert.Contains(t, result.Hits, int64(2))
317
318 // Combined: plain label + label value
319 o = opts()
320 o.Labels = []string{"feature"}
321 o.LabelValues = []string{"priority:high"}
322 result, err = ix.Search(ctx, o)
323 require.NoError(t, err)
324 assert.Equal(t, uint64(1), result.Total)
325 assert.Contains(t, result.Hits, int64(3))
326
327 // Negated label value
328 o = opts()
329 o.NegatedLabelValues = []string{"priority:low"}
330 result, err = ix.Search(ctx, o)
331 require.NoError(t, err)
332 assert.Equal(t, uint64(2), result.Total)
333 assert.Contains(t, result.Hits, int64(1))
334 assert.Contains(t, result.Hits, int64(3))
335
336 // Label value + negated plain label
337 o = opts()
338 o.LabelValues = []string{"priority:high"}
339 o.NegatedLabels = []string{"feature"}
340 result, err = ix.Search(ctx, o)
341 require.NoError(t, err)
342 assert.Equal(t, uint64(1), result.Total)
343 assert.Contains(t, result.Hits, int64(1))
344}