Monorepo for Tangled
at push-rukyyyptkmtm 344 lines 11 kB view raw
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}