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