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.
|
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, ¬used, &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 | } |