e2e/pagination_test.go
197 lines · 6708 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9)
10
11// pageOf unmarshals a paged --json response.
12type pageOf[T any] struct {
13 Data struct {
14 Items []T `json:"items"`
15 Next string `json:"next"`
16 } `json:"data"`
17}
18
19func decodePage[T any](t *testing.T, out string) ([]T, string) {
20 t.Helper()
21 var p pageOf[T]
22 if err := json.Unmarshal([]byte(out), &p); err != nil {
23 t.Fatalf("bad json: %v\n%s", err, out)
24 }
25 return p.Data.Items, p.Data.Next
26}
27
28// Cursor pagination on the list commands: opaque cursors, stable pages,
29// and the bare-array shape untouched when the flags are absent.
30func TestCursorPagination(t *testing.T) {
31 t.Parallel()
32 inst := startInstance(t)
33 aliceKey := inst.newKey(t, "alice")
34 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
35
36 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
37 t.Fatalf("repo create: %s", errOut)
38 }
39 for _, name := range []string{"'one'", "'two'", "'three'"} {
40 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", name); code != 0 {
41 t.Fatal("issue create failed")
42 }
43 }
44
45 // Bare list: still a plain array.
46 out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--json")
47 if code != 0 {
48 t.Fatal("issue list failed")
49 }
50 var bare struct {
51 Data []struct {
52 Number int64 `json:"number"`
53 } `json:"data"`
54 }
55 if err := json.Unmarshal([]byte(out), &bare); err != nil || len(bare.Data) != 3 {
56 t.Fatalf("bare issue list: %v\n%s", err, out)
57 }
58
59 type numbered struct {
60 Number int64 `json:"number"`
61 }
62 // Page 1: newest two, with a cursor onward.
63 out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--json")
64 if code != 0 {
65 t.Fatal("paged issue list failed")
66 }
67 items, next := decodePage[numbered](t, out)
68 if len(items) != 2 || items[0].Number != 3 || items[1].Number != 2 || next == "" {
69 t.Fatalf("page 1: %+v next=%q", items, next)
70 }
71 // Page 2: the remainder, no cursor.
72 out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--cursor", next, "--json")
73 if code != 0 {
74 t.Fatal("cursor follow failed")
75 }
76 items, next = decodePage[numbered](t, out)
77 if len(items) != 1 || items[0].Number != 1 || next != "" {
78 t.Fatalf("page 2: %+v next=%q", items, next)
79 }
80
81 // A cursor from one command is refused by another.
82 out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--json")
83 items, next = decodePage[numbered](t, out)
84 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--cursor", next); code != 2 {
85 t.Fatal("foreign cursor accepted")
86 }
87 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--cursor", "garbage!"); code != 2 {
88 t.Fatal("garbage cursor accepted")
89 }
90 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "0"); code != 2 {
91 t.Fatal("limit 0 accepted")
92 }
93
94 // MR pagination pages by number the same way.
95 work := t.TempDir()
96 env := inst.gitEnv(aliceKey)
97 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
98 dir := filepath.Join(work, "w")
99 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
100 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
101 mustGit(t, dir, env, "add", ".")
102 mustGit(t, dir, env, "commit", "-q", "-m", "base")
103 mustGit(t, dir, env, "push", "-q", "origin", "main")
104 for i := 1; i <= 2; i++ {
105 branch := fmt.Sprintf("feat%d", i)
106 mustGit(t, dir, env, "checkout", "-q", "-b", branch, "main")
107 os.WriteFile(filepath.Join(dir, "a.txt"), []byte(fmt.Sprintf("a\n%d\n", i)), 0o644)
108 mustGit(t, dir, env, "add", ".")
109 mustGit(t, dir, env, "commit", "-q", "-m", branch)
110 mustGit(t, dir, env, "push", "-q", "origin", branch)
111 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
112 "--source", branch, "--target", "main", "--title", branch); code != 0 {
113 t.Fatal("mr create failed")
114 }
115 }
116 out, _, code = inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--limit", "1", "--json")
117 if code != 0 {
118 t.Fatal("paged mr list failed")
119 }
120 items, next = decodePage[numbered](t, out)
121 if len(items) != 1 || items[0].Number != 2 || next == "" {
122 t.Fatalf("mr page 1: %+v next=%q", items, next)
123 }
124 out, _, code = inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--limit", "1", "--cursor", next, "--json")
125 items, next = decodePage[numbered](t, out)
126 if len(items) != 1 || items[0].Number != 1 || next != "" {
127 t.Fatalf("mr page 2: %+v next=%q", items, next)
128 }
129
130 // Repo pagination pages by path.
131 for _, name := range []string{"alice/butter", "alice/cheese"} {
132 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", name); code != 0 {
133 t.Fatal("repo create failed")
134 }
135 }
136 type pathed struct {
137 Path string `json:"path"`
138 }
139 out, _, code = inst.ssh(t, aliceKey, "", "repo", "list", "--limit", "2", "--json")
140 if code != 0 {
141 t.Fatal("paged repo list failed")
142 }
143 rItems, next := decodePage[pathed](t, out)
144 if len(rItems) != 2 || rItems[0].Path != "alice/app" || rItems[1].Path != "alice/butter" || next == "" {
145 t.Fatalf("repo page 1: %+v next=%q", rItems, next)
146 }
147 out, _, code = inst.ssh(t, aliceKey, "", "repo", "list", "--limit", "2", "--cursor", next, "--json")
148 rItems, next = decodePage[pathed](t, out)
149 if len(rItems) != 1 || rItems[0].Path != "alice/cheese" || next != "" {
150 t.Fatalf("repo page 2: %+v next=%q", rItems, next)
151 }
152
153 // The feed pages by event id, newest first.
154 type feedRow struct {
155 Kind string `json:"kind"`
156 Repo string `json:"repo"`
157 }
158 out, _, code = inst.ssh(t, aliceKey, "", "feed", "--limit", "2", "--json")
159 if code != 0 {
160 t.Fatal("paged feed failed")
161 }
162 fItems, next := decodePage[feedRow](t, out)
163 if len(fItems) != 2 || next == "" {
164 t.Fatalf("feed page 1: %+v next=%q", fItems, next)
165 }
166 if fItems[0].Kind != "mr.created" || fItems[0].Repo != "alice/app" {
167 t.Fatalf("feed head: %+v", fItems[0])
168 }
169 seen := len(fItems)
170 for next != "" {
171 out, _, code = inst.ssh(t, aliceKey, "", "feed", "--limit", "2", "--cursor", next, "--json")
172 if code != 0 {
173 t.Fatal("feed follow failed")
174 }
175 fItems, next = decodePage[feedRow](t, out)
176 seen += len(fItems)
177 if seen > 20 {
178 t.Fatal("feed cursor loop")
179 }
180 }
181 // 3 issues + 2 MRs created above.
182 if seen != 5 {
183 t.Fatalf("feed walked %d events", seen)
184 }
185
186 // Bare feed: plain array, most recent first.
187 out, _, code = inst.ssh(t, aliceKey, "", "feed", "--json")
188 if code != 0 {
189 t.Fatal("feed failed")
190 }
191 var bareFeed struct {
192 Data []feedRow `json:"data"`
193 }
194 if err := json.Unmarshal([]byte(out), &bareFeed); err != nil || len(bareFeed.Data) != 5 {
195 t.Fatalf("bare feed: %v\n%s", err, out)
196 }
197}