internal/store/savedqueries_test.go
283 lines · 8858 bytes
9 symbols in this file
1package store
2
3import (
4 "errors"
5 "fmt"
6 "testing"
7)
8
9type queryFixture struct {
10 s *Store
11 alice, bob, carol int64
12 pub, priv, bobPriv Repo
13}
14
15// newQueryFixture: alice owns a public and a private repository, bob a
16// private one; carol owns nothing and is granted nothing.
17func newQueryFixture(t *testing.T) queryFixture {
18 t.Helper()
19 s := open(t)
20 if err := s.MigrateUp(); err != nil {
21 t.Fatal(err)
22 }
23 var f queryFixture
24 f.s = s
25 must := func(id int64, err error) int64 {
26 t.Helper()
27 if err != nil {
28 t.Fatal(err)
29 }
30 return id
31 }
32 f.alice = must(s.CreateUser("alice", false))
33 f.bob = must(s.CreateUser("bob", false))
34 f.carol = must(s.CreateUser("carol", false))
35 repo := func(owner int64, name, vis string) Repo {
36 r, err := s.RepoByID(must(s.CreateRepo("user", owner, name, vis)))
37 if err != nil {
38 t.Fatal(err)
39 }
40 return r
41 }
42 f.pub = repo(f.alice, "pub", "public")
43 f.priv = repo(f.alice, "priv", "private")
44 f.bobPriv = repo(f.bob, "secret", "private")
45 return f
46}
47
48func (f queryFixture) issue(t *testing.T, r Repo, author int64, title string) Issue {
49 t.Helper()
50 n, err := f.s.CreateIssue(r.ID, author, title, "", "md")
51 if err != nil {
52 t.Fatal(err)
53 }
54 i, err := f.s.IssueByNumber(r.ID, n)
55 if err != nil {
56 t.Fatal(err)
57 }
58 return i
59}
60
61func (f queryFixture) mr(t *testing.T, r Repo, author int64, title string) MR {
62 t.Helper()
63 n, err := f.s.CreateMR(r.ID, author, r.ID, "topic", "main", title, "", "", "md", false)
64 if err != nil {
65 t.Fatal(err)
66 }
67 m, err := f.s.MRByNumber(r.ID, n)
68 if err != nil {
69 t.Fatal(err)
70 }
71 return m
72}
73
74func titles(items []Item) []string {
75 var out []string
76 for _, it := range items {
77 out = append(out, it.Title)
78 }
79 return out
80}
81
82// A query reaches only what the caller may read: someone else's private
83// repository is absent from the rows and from the count, even when the
84// query names it.
85func TestQueryItemsSkipsUnreadableRepositories(t *testing.T) {
86 f := newQueryFixture(t)
87 f.issue(t, f.pub, f.alice, "public bug")
88 f.issue(t, f.priv, f.alice, "alice private bug")
89 f.issue(t, f.bobPriv, f.bob, "bob private bug")
90 f.mr(t, f.bobPriv, f.bob, "bob private mr")
91 f.mr(t, f.pub, f.alice, "public mr")
92
93 all := ItemFilter{Issues: true, MRs: true}
94 for _, tc := range []struct {
95 name string
96 user int64
97 f ItemFilter
98 want int
99 }{
100 {"carol, everything", f.carol, all, 2},
101 {"alice, everything", f.alice, all, 3},
102 {"bob, everything", f.bob, all, 4},
103 {"carol naming bob's repository", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob", Name: "secret"}}}, 0},
104 {"carol naming bob", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob"}}}, 0},
105 {"carol globbing alice", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 2},
106 {"alice globbing herself", f.alice, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 3},
107 {"carol by bob's text", f.carol, ItemFilter{Issues: true, MRs: true, Text: "bob"}, 0},
108 } {
109 items, err := f.s.QueryItems(tc.user, tc.f, nil, 0)
110 if err != nil {
111 t.Fatal(err)
112 }
113 n, err := f.s.CountItems(tc.user, tc.f)
114 if err != nil {
115 t.Fatal(err)
116 }
117 if len(items) != tc.want || n != tc.want {
118 t.Errorf("%s: %d rows %q, count %d; want %d", tc.name, len(items), titles(items), n, tc.want)
119 }
120 }
121
122 // A grant opens bob's repository to carol.
123 if err := f.s.GrantAccess(f.bobPriv.ID, f.carol, "read"); err != nil {
124 t.Fatal(err)
125 }
126 if n, _ := f.s.CountItems(f.carol, all); n != 4 {
127 t.Errorf("carol with a grant counts %d, want 4", n)
128 }
129}
130
131func TestQueryItemsFilters(t *testing.T) {
132 f := newQueryFixture(t)
133 bug := f.issue(t, f.pub, f.alice, "labelled bug")
134 if err := f.s.SetIssueLabel(f.pub, bug.ID, "bug", true); err != nil {
135 t.Fatal(err)
136 }
137 if err := f.s.SetIssueLabel(f.pub, bug.ID, "ui", true); err != nil {
138 t.Fatal(err)
139 }
140 both := f.issue(t, f.pub, f.bob, "assigned in milestone")
141 ms, err := f.s.CreateMilestone(f.pub, "v2", "", "")
142 if err != nil {
143 t.Fatal(err)
144 }
145 if err := f.s.SetIssueMilestone(both.ID, ms); err != nil {
146 t.Fatal(err)
147 }
148 if err := f.s.SetIssueAssignee(both.ID, f.carol, true); err != nil {
149 t.Fatal(err)
150 }
151 closed := f.issue(t, f.pub, f.alice, "closed one")
152 if err := f.s.SetIssueState(closed.ID, "closed"); err != nil {
153 t.Fatal(err)
154 }
155 merged := f.mr(t, f.pub, f.alice, "merged mr")
156 if err := f.s.MarkMerged(merged.ID, "", f.alice, ""); err != nil {
157 t.Fatal(err)
158 }
159 f.mr(t, f.pub, f.bob, "open mr")
160
161 for _, tc := range []struct {
162 f ItemFilter
163 want string
164 }{
165 {ItemFilter{Issues: true, MRs: true, State: "open"}, "[open mr assigned in milestone labelled bug]"},
166 {ItemFilter{Issues: true, MRs: true, State: "merged"}, "[merged mr]"},
167 {ItemFilter{Issues: true, State: "closed"}, "[closed one]"},
168 {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "ui"}}, "[labelled bug]"},
169 {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "nope"}}, "[]"},
170 {ItemFilter{Issues: true, NoLabel: true}, "[closed one assigned in milestone]"},
171 {ItemFilter{Issues: true, Milestone: "v2"}, "[assigned in milestone]"},
172 {ItemFilter{Issues: true, NoMilestone: true}, "[closed one labelled bug]"},
173 {ItemFilter{Issues: true, Assignee: "carol"}, "[assigned in milestone]"},
174 {ItemFilter{Issues: true, MRs: true, Author: "bob"}, "[open mr assigned in milestone]"},
175 {ItemFilter{MRs: true}, "[open mr merged mr]"},
176 {ItemFilter{}, "[]"},
177 } {
178 items, err := f.s.QueryItems(f.alice, tc.f, nil, 0)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if got := fmt.Sprint(titles(items)); got != tc.want && !(got == "[]" && tc.want == "[]") {
183 t.Errorf("%+v = %s, want %s", tc.f, got, tc.want)
184 }
185 }
186}
187
188// Paging walks every row once across repositories and both tables, even
189// when rows share a creation time: the cursor carries the tie-breakers.
190func TestQueryItemsPagesAcrossRepositories(t *testing.T) {
191 f := newQueryFixture(t)
192 var want []string
193 for i := 0; i < 5; i++ {
194 f.issue(t, f.pub, f.alice, fmt.Sprintf("pub issue %d", i))
195 f.issue(t, f.priv, f.alice, fmt.Sprintf("priv issue %d", i))
196 f.mr(t, f.pub, f.alice, fmt.Sprintf("pub mr %d", i))
197 f.issue(t, f.bobPriv, f.bob, fmt.Sprintf("bob issue %d", i))
198 }
199 // Half the rows at one instant, so ordering falls to kind and id.
200 if _, err := f.s.DB.Exec("UPDATE issues SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 0"); err != nil {
201 t.Fatal(err)
202 }
203 if _, err := f.s.DB.Exec("UPDATE merge_requests SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 1"); err != nil {
204 t.Fatal(err)
205 }
206 all := ItemFilter{Issues: true, MRs: true}
207 full, err := f.s.QueryItems(f.alice, all, nil, 0)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if len(full) != 15 {
212 t.Fatalf("alice reads %d rows, want 15", len(full))
213 }
214 for _, it := range full {
215 want = append(want, it.Kind+it.Title)
216 }
217 var got []string
218 var after *ItemCursor
219 for pages := 0; ; pages++ {
220 if pages > 20 {
221 t.Fatal("paging does not end")
222 }
223 page, err := f.s.QueryItems(f.alice, all, after, 4)
224 if err != nil {
225 t.Fatal(err)
226 }
227 for _, it := range page {
228 got = append(got, it.Kind+it.Title)
229 }
230 if len(page) < 4 {
231 break
232 }
233 c := page[len(page)-1].Cursor()
234 after = &c
235 }
236 if fmt.Sprint(got) != fmt.Sprint(want) {
237 t.Errorf("paged\n%v\nwant\n%v", got, want)
238 }
239}
240
241func TestSavedQueries(t *testing.T) {
242 f := newQueryFixture(t)
243 s := f.s
244 if err := s.SaveQuery(f.alice, "mine", "is:open", false); err != nil {
245 t.Fatal(err)
246 }
247 if err := s.SaveQuery(f.alice, "mine", "is:closed", false); !errors.Is(err, ErrExists) {
248 t.Fatalf("second save without replace: %v, want ErrExists", err)
249 }
250 if err := s.PinSavedQuery(f.alice, "mine", true); err != nil {
251 t.Fatal(err)
252 }
253 if err := s.SaveQuery(f.alice, "mine", "is:closed", true); err != nil {
254 t.Fatal(err)
255 }
256 q, err := s.SavedQueryByName(f.alice, "mine")
257 if err != nil || q.Query != "is:closed" || !q.Pinned {
258 t.Fatalf("after replace: %+v %v; want the new text, still pinned", q, err)
259 }
260 // Names are per user.
261 if err := s.SaveQuery(f.bob, "mine", "is:open", false); err != nil {
262 t.Fatal(err)
263 }
264 if _, err := s.SavedQueryByName(f.carol, "mine"); !errors.Is(err, ErrNotFound) {
265 t.Fatalf("carol sees alice's query: %v", err)
266 }
267 if err := s.SaveQuery(f.alice, "other", "is:open", false); err != nil {
268 t.Fatal(err)
269 }
270 pinned, err := s.SavedQueries(f.alice, true)
271 if err != nil || len(pinned) != 1 || pinned[0].Name != "mine" {
272 t.Fatalf("pinned = %+v %v", pinned, err)
273 }
274 if err := s.RemoveSavedQuery(f.alice, "mine"); err != nil {
275 t.Fatal(err)
276 }
277 if err := s.RemoveSavedQuery(f.alice, "mine"); !errors.Is(err, ErrNotFound) {
278 t.Fatalf("second remove: %v", err)
279 }
280 if err := s.PinSavedQuery(f.alice, "gone", true); !errors.Is(err, ErrNotFound) {
281 t.Fatalf("pin of a missing query: %v", err)
282 }
283}