Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · forge/index/events_test.go

Line attribution on main · 343 line(s)

bca9ec24
Keenan Nemetz
10d ago
1 package index
2
3 // The events table is the feed's read model. These tests hold what it has to say
4 // and, just as important, what it must stop saying: an edit changes the title on
5 // old events, a deletion removes them, and a state change that leaves the tree
6 // where it started still has to appear.
7
8 import (
9 "strings"
10 "testing"
11 "time"
12
d37d301f
Keenan Nemetz
21h ago
13 "rodent.software/vole/core/git"
14 "rodent.software/vole/metadata"
bca9ec24
Keenan Nemetz
10d ago
15 )
16
17 func eventSummaries(t *testing.T, s *Store, repoID string) []string {
18 t.Helper()
19 rows, err := s.Events([]string{repoID}, "", "", "", 1000, 0)
20 if err != nil {
21 t.Fatal(err)
22 }
23 out := make([]string, 0, len(rows))
24 for _, r := range rows {
25 out = append(out, r.Kind+"/"+r.Type+"/"+r.Title)
26 }
27 return out
28 }
29
30 func TestEventsCoverTheFeed(t *testing.T) {
31 t.Parallel()
32 s, m, g := incrementalRepo(t)
33 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
34 iss := createIndexedIssue(t, g, "An issue", now, nil, []string{"keenan"})
35 addIssueComment(t, g, iss.ID, "a note", now.Add(time.Minute))
36 req := createIndexedRequest(t, g, "A change", now.Add(2*time.Minute), nil)
37 comment, err := metadata.NewRequestComment("review note", "wilbur", now.Add(3*time.Minute))
38 if err != nil {
39 t.Fatal(err)
40 }
41 if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil {
42 t.Fatal(err)
43 }
44 if _, err := metadata.ApproveRequest(g, req.ID, "wilbur", "main", requestHead, now.Add(4*time.Minute), "wilbur"); err != nil {
45 t.Fatal(err)
46 }
47 tip, err := g.RevParse(metadata.Ref)
48 if err != nil || tip == "" {
49 t.Fatalf("resolve metadata tip: %q, %v", tip, err)
50 }
51 if err := g.WriteAnnotatedTag("v1", tip, "notes\n", "keenan", false); err != nil {
52 t.Fatal(err)
53 }
54 if err := s.Ensure(m); err != nil {
55 t.Fatal(err)
56 }
57 if err := s.RefreshTags(m); err != nil {
58 t.Fatal(err)
59 }
60
61 got := eventSummaries(t, s, m.ID)
62 for _, want := range []string{
63 "issue/open/An issue",
64 "issue/comment/An issue",
65 "change/open/A change",
66 "change/comment/A change",
67 "change/approval/A change",
68 "release/release/v1",
69 } {
70 found := false
71 for _, g := range got {
72 if g == want {
73 found = true
74 break
75 }
76 }
77 if !found {
78 t.Fatalf("events are missing %q: %v", want, got)
79 }
80 }
81 var count int
82 if err := s.db.QueryRow(`SELECT events_count FROM repos WHERE id=?`, m.ID).Scan(&count); err != nil {
83 t.Fatal(err)
84 }
85 if count != len(got) {
86 t.Fatalf("events_count = %d, want %d", count, len(got))
87 }
88 if n, err := s.EventCount([]string{m.ID}, "", "", ""); err != nil || n != len(got) {
89 t.Fatalf("EventCount = %d, %v; want %d", n, err, len(got))
90 }
91
92 // The rebuild must produce the same feed.
93 if err := s.IndexRepo(m); err != nil {
94 t.Fatal(err)
95 }
96 rebuilt := eventSummaries(t, s, m.ID)
97 if strings.Join(rebuilt, "\n") != strings.Join(got, "\n") {
98 t.Fatalf("rebuilt events differ\n got: %v\nwant: %v", rebuilt, got)
99 }
100 }
101
102 func TestEventTitlesFollowTheEntityAndDeletionRemovesItsEvents(t *testing.T) {
103 t.Parallel()
104 s, m, g := incrementalRepo(t)
105 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
106 iss := createIndexedIssue(t, g, "Old title", now, nil, nil)
107 addIssueComment(t, g, iss.ID, "a note", now.Add(time.Minute))
108 if err := s.Ensure(m); err != nil {
109 t.Fatal(err)
110 }
111 if _, err := metadata.UpdateIssue(g, iss.ID, func(iss *metadata.Issue) error {
112 iss.Title = "New title"
113 return nil
114 }, "keenan"); err != nil {
115 t.Fatal(err)
116 }
117 if err := s.Ensure(m); err != nil {
118 t.Fatal(err)
119 }
120 for _, ev := range eventSummaries(t, s, m.ID) {
121 if !strings.HasSuffix(ev, "/New title") {
122 t.Fatalf("event still carries the old title: %v", ev)
123 }
124 }
125
126 if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error {
127 for path := range files {
128 if strings.HasPrefix(path, "issues/"+iss.ID+"/") {
129 delete(files, path)
130 }
131 }
132 return nil
133 }, "delete the issue", "keenan"); err != nil {
134 t.Fatal(err)
135 }
136 if err := s.Ensure(m); err != nil {
137 t.Fatal(err)
138 }
139 if got := eventSummaries(t, s, m.ID); len(got) != 0 {
140 t.Fatalf("events survived the deletion: %v", got)
141 }
142 if n, err := s.EventCount([]string{m.ID}, "", "", ""); err != nil || n != 0 {
143 t.Fatalf("EventCount after deletion = %d, %v", n, err)
144 }
145 }
146
147 // TestStateChangeEventsSurviveATreeRoundTrip is the case an endpoint diff cannot
148 // see: close and reopen inside one refresh leaves the file where it started, and
149 // both events still have to be in the feed.
150 func TestStateChangeEventsSurviveATreeRoundTrip(t *testing.T) {
151 t.Parallel()
152 s, m, g := incrementalRepo(t)
153 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
154 iss := createIndexedIssue(t, g, "Round trip", now, nil, nil)
155 if err := s.Ensure(m); err != nil {
156 t.Fatal(err)
157 }
158 for _, state := range []string{"closed", "open"} {
159 if _, err := metadata.SetIssueState(g, iss.ID, state, "keenan"); err != nil {
160 t.Fatal(err)
161 }
162 }
163 if err := s.Ensure(m); err != nil {
164 t.Fatal(err)
165 }
166 var states int
167 rows, err := s.Events([]string{m.ID}, "issue", "", "", 100, 0)
168 if err != nil {
169 t.Fatal(err)
170 }
171 for _, r := range rows {
172 if r.Type == "state" {
173 states++
174 }
175 }
176 if states != 2 {
177 t.Fatalf("state events = %d, want 2: %+v", states, rows)
178 }
179 }
180
181 func TestRefreshTagsReplacesReleaseEvents(t *testing.T) {
182 t.Parallel()
183 s, m, g := incrementalRepo(t)
184 tip, err := g.RevParse(metadata.Ref)
185 if err != nil || tip == "" {
186 t.Fatalf("resolve metadata tip: %q, %v", tip, err)
187 }
188 if err := g.WriteAnnotatedTag("v1", tip, "notes\n", "keenan", false); err != nil {
189 t.Fatal(err)
190 }
191 if err := s.RefreshTags(m); err != nil {
192 t.Fatal(err)
193 }
194 if got := eventSummaries(t, s, m.ID); len(got) != 1 || got[0] != "release/release/v1" {
195 t.Fatalf("release events = %v", got)
196 }
197 if err := g.DeleteRef("refs/tags/v1"); err != nil {
198 t.Fatal(err)
199 }
200 if err := s.RefreshTags(m); err != nil {
201 t.Fatal(err)
202 }
203 if got := eventSummaries(t, s, m.ID); len(got) != 0 {
204 t.Fatalf("release events survived the tag deletion: %v", got)
205 }
206 }
207
208 func TestEventsPaginate(t *testing.T) {
209 t.Parallel()
210 s, m, g := incrementalRepo(t)
211 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
212 for i := 0; i < 55; i++ {
213 createIndexedIssue(t, g, "issue", now.Add(time.Duration(i)*time.Minute), nil, nil)
214 }
215 if err := s.Ensure(m); err != nil {
216 t.Fatal(err)
217 }
218 first, err := s.Events([]string{m.ID}, "", "", "", 50, 0)
219 if err != nil || len(first) != 50 {
220 t.Fatalf("first page = %d, %v", len(first), err)
221 }
222 second, err := s.Events([]string{m.ID}, "", "", "", 50, 50)
223 if err != nil || len(second) != 5 {
224 t.Fatalf("second page = %d, %v", len(second), err)
225 }
226 if n, err := s.EventCount([]string{m.ID}, "", "", ""); err != nil || n != 55 {
227 t.Fatalf("count = %d, %v", n, err)
228 }
229 }
230
231 // TestEventPageDoesNotSortHistory pins the design: one repository is an index
232 // range, and the instance feed walks the time index and filters. If a later
233 // change makes SQLite sort the events, the page cost goes back to the history.
234 func TestEventPageDoesNotSortHistory(t *testing.T) {
235 t.Parallel()
236 s, err := Open(t.TempDir() + "/index.db")
237 if err != nil {
238 t.Fatal(err)
239 }
240 defer s.Close()
a6b42433
Keenan Nemetz
8d ago
241 plan := func(repoIDs []string, q eventQuery) string {
242 query, args := eventPageQuery(repoIDs, q, 50, 100)
243 rows, err := s.db.Query("EXPLAIN QUERY PLAN "+query, args...)
bca9ec24
Keenan Nemetz
10d ago
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer rows.Close()
248 var b strings.Builder
249 for rows.Next() {
250 var id, parent, notused int
251 var detail string
252 if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
253 t.Fatal(err)
254 }
255 b.WriteString(detail + "\n")
256 }
257 return b.String()
258 }
a6b42433
Keenan Nemetz
8d ago
259 if got := plan([]string{"r1"}, eventQuery{}); !strings.Contains(got, "events_repo") || strings.Contains(got, "TEMP B-TREE") {
bca9ec24
Keenan Nemetz
10d ago
260 t.Fatalf("one repository does not page by index:\n%s", got)
261 }
a6b42433
Keenan Nemetz
8d ago
262 if got := plan([]string{"r1", "r2"}, eventQuery{}); !strings.Contains(got, "events_time") || strings.Contains(got, "TEMP B-TREE") {
bca9ec24
Keenan Nemetz
10d ago
263 t.Fatalf("several repositories do not page by time index:\n%s", got)
264 }
a6b42433
Keenan Nemetz
8d ago
265 // A profile is one account's rows, whatever the viewer can read: without the
266 // actor index that becomes a scan of every visible repository's history.
267 if got := plan([]string{"r1", "r2"}, eventQuery{actor: "ada"}); !strings.Contains(got, "events_actor") || strings.Contains(got, "TEMP B-TREE") {
268 t.Fatalf("a profile does not page by actor index:\n%s", got)
269 }
bca9ec24
Keenan Nemetz
10d ago
270 }
271
272 func TestEventsOrderAcrossRepositories(t *testing.T) {
273 t.Parallel()
274 s, m, g := incrementalRepo(t)
275 other, og := newSecondRepo(t, s, "other")
276 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
277 createIndexedIssue(t, g, "demo one", now, nil, nil)
278 createIndexedIssue(t, g, "demo two", now.Add(2*time.Minute), nil, nil)
279 first, err := metadata.NewIssue("other one", "body", "keenan", nil, nil, now.Add(time.Minute))
280 if err != nil {
281 t.Fatal(err)
282 }
283 if err := metadata.CreateIssue(og, first, "keenan"); err != nil {
284 t.Fatal(err)
285 }
286 if err := s.Ensure(m); err != nil {
287 t.Fatal(err)
288 }
289 if err := s.Ensure(other); err != nil {
290 t.Fatal(err)
291 }
292 rows, err := s.Events([]string{m.ID, other.ID}, "", "", "", 10, 0)
293 if err != nil {
294 t.Fatal(err)
295 }
296 if len(rows) != 3 {
297 t.Fatalf("events = %d, want 3: %+v", len(rows), rows)
298 }
299 for i := 1; i < len(rows); i++ {
300 if rows[i-1].At < rows[i].At {
301 t.Fatalf("events are not newest first: %+v", rows)
302 }
303 }
304 if n, err := s.EventCount([]string{m.ID, other.ID}, "", "", ""); err != nil || n != 3 {
305 t.Fatalf("count = %d, %v", n, err)
306 }
307 page, err := s.Events([]string{m.ID, other.ID}, "", "", "", 2, 2)
308 if err != nil || len(page) != 1 {
309 t.Fatalf("second page = %+v, %v", page, err)
310 }
311 }
20442b59
Keenan Nemetz
10d ago
312
313 func TestLastEventTimesIsPerRepository(t *testing.T) {
314 t.Parallel()
315 s, m, g := incrementalRepo(t)
316 other, og := newSecondRepo(t, s, "other")
317 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
318 createIndexedIssue(t, g, "demo old", now, nil, nil)
319 createIndexedIssue(t, g, "demo new", now.Add(5*time.Minute), nil, nil)
320 iss, err := metadata.NewIssue("other", "body", "keenan", nil, nil, now.Add(time.Minute))
321 if err != nil {
322 t.Fatal(err)
323 }
324 if err := metadata.CreateIssue(og, iss, "keenan"); err != nil {
325 t.Fatal(err)
326 }
327 if err := s.Ensure(m); err != nil {
328 t.Fatal(err)
329 }
330 if err := s.Ensure(other); err != nil {
331 t.Fatal(err)
332 }
333 times, err := s.LastEventTimes([]string{m.ID, other.ID})
334 if err != nil {
335 t.Fatal(err)
336 }
337 if !times[m.ID].Equal(now.Add(5 * time.Minute)) {
338 t.Fatalf("demo last event = %s, want %s", times[m.ID], now.Add(5*time.Minute))
339 }
340 if !times[other.ID].Equal(now.Add(time.Minute)) {
341 t.Fatalf("other last event = %s, want %s", times[other.ID], now.Add(time.Minute))
342 }
343 }