vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 26m ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // Profiles and the account directory. The point of these tests is the visibility |
| 4 | // rule: an account is public, its work is not. The same page must count one |
| 5 | // repository for an anonymous reader and two for somebody who can read both, |
| 6 | // without ever hinting that the second one exists. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "fmt" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "rodent.software/vole/forge/access" |
| 19 | "rodent.software/vole/forge/config" |
| 20 | "rodent.software/vole/forge/identity" |
| 21 | "rodent.software/vole/forge/index" |
| 22 | "rodent.software/vole/forge/repo" |
| 23 | "rodent.software/vole/forge/session" |
| 24 | "rodent.software/vole/metadata" |
| 25 | ) |
| 26 | |
| 27 | // site is a forge with a roster and two repositories: "demo" is readable by |
| 28 | // anybody, "secret" only by alice. Each holds one issue, so a profile's feed |
| 29 | // either names the hidden one or it does not. |
| 30 | type site struct { |
| 31 | web *Web |
| 32 | ident *identity.Store |
| 33 | acls *access.Store |
| 34 | } |
| 35 | |
| 36 | func newSite(t *testing.T) *site { |
| 37 | t.Helper() |
| 38 | root := t.TempDir() |
| 39 | users := filepath.Join(root, "users.db") |
| 40 | if err := identity.Init(users); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | ident, err := identity.Open(users) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | t.Cleanup(func() { ident.Close() }) |
| 48 | ident.AuditPath = filepath.Join(root, "audit.log") |
| 49 | |
| 50 | acls, err := access.Open(users) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | t.Cleanup(func() { acls.Close() }) |
| 55 | acls.AuditPath = filepath.Join(root, "audit.log") |
| 56 | |
| 57 | ix, err := index.Open(filepath.Join(root, "index.db")) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | t.Cleanup(func() { ix.Close() }) |
| 62 | |
| 63 | w, err := New(repo.New(filepath.Join(root, "repos"), "", acls), ix, ident, nil, &config.Config{DataDir: root}) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | for _, name := range []string{"alice", "adam", "bob"} { |
| 68 | if _, _, err := ident.CreateWithInvite(name, "setup", time.Hour); err != nil { |
| 69 | t.Fatalf("create %s: %v", name, err) |
| 70 | } |
| 71 | } |
| 72 | s := &site{web: w, ident: ident, acls: acls} |
| 73 | s.repo(t, "demo", "alice", true, "Public work") |
| 74 | s.repo(t, "secret", "alice", false, "Hidden work") |
| 75 | return s |
| 76 | } |
| 77 | |
| 78 | func (s *site) repo(t *testing.T, name, owner string, readableByAnyone bool, issueTitle string) { |
| 79 | t.Helper() |
| 80 | m, err := s.web.Repos.Create(name, owner, "") |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if readableByAnyone { |
| 85 | acl, err := m.ACL() |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | acl.Default = "read" |
| 90 | if err := s.acls.Set(m.ID, acl, owner, time.Now()); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | } |
| 94 | if err := s.web.Index.Upsert(m); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | iss, err := metadata.NewIssue(issueTitle, "body", "alice", nil, nil, time.Now().UTC()) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | if err := metadata.CreateIssue(m.Git(), iss, owner); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err := s.web.Index.IndexRepo(m); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // serve calls a handler through a mux, because the handlers read the account name |
| 110 | // from the route pattern. A nil account is an anonymous reader. |
| 111 | func (s *site) serve(t *testing.T, as *identity.Account, path string) *httptest.ResponseRecorder { |
| 112 | t.Helper() |
| 113 | mux := http.NewServeMux() |
| 114 | mux.HandleFunc("GET /-/u", s.web.PeoplePage) |
| 115 | mux.HandleFunc("GET /-/u/{$}", s.web.PeoplePage) |
| 116 | mux.HandleFunc("GET /-/u/{name}", s.web.ProfilePage) |
| 117 | r := httptest.NewRequest("GET", path, nil) |
| 118 | if as != nil { |
| 119 | r = r.WithContext(context.WithValue(r.Context(), principalKey{}, |
| 120 | &principal{account: as, session: &session.Session{}})) |
| 121 | } |
| 122 | rw := httptest.NewRecorder() |
| 123 | mux.ServeHTTP(rw, r) |
| 124 | return rw |
| 125 | } |
| 126 | |
| 127 | func (s *site) account(t *testing.T, name string) *identity.Account { |
| 128 | t.Helper() |
| 129 | a, err := s.ident.Find(name) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | return a |
| 134 | } |
| 135 | |
| 136 | // TestProfileCountsOnlyWhatTheViewerCanRead is the privacy rule of the page: the |
| 137 | // same account, two viewers, and no sign of the repository the anonymous reader |
| 138 | // cannot open. |
| 139 | func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) { |
| 140 | t.Parallel() |
| 141 | s := newSite(t) |
| 142 | |
| 143 | anon := s.serve(t, nil, "/-/u/alice") |
| 144 | if anon.Code != http.StatusOK { |
| 145 | t.Fatalf("anonymous profile = %d: %s", anon.Code, anon.Body) |
| 146 | } |
| 147 | body := anon.Body.String() |
| 148 | if !strings.Contains(body, "Public work") { |
| 149 | t.Fatalf("the anonymous profile is missing the public issue:\n%s", body) |
| 150 | } |
| 151 | if strings.Contains(body, "Hidden work") { |
| 152 | t.Fatalf("the anonymous profile leaked a private issue:\n%s", body) |
| 153 | } |
| 154 | // The page says the numbers are scoped rather than pretending they are total. |
| 155 | if !strings.Contains(body, "Counted across the repositories you can read.") { |
| 156 | t.Fatalf("the profile does not say what the numbers cover:\n%s", body) |
| 157 | } |
| 158 | // A profile is public, but a scraped roster is not what public has to mean. |
| 159 | if !strings.Contains(body, `<meta name="robots" content="noindex">`) { |
| 160 | t.Fatalf("the profile does not ask crawlers to leave it alone:\n%s", body) |
| 161 | } |
| 162 | |
| 163 | signed := s.serve(t, s.account(t, "alice"), "/-/u/alice") |
| 164 | if signed.Code != http.StatusOK { |
| 165 | t.Fatalf("signed-in profile = %d", signed.Code) |
| 166 | } |
| 167 | if !strings.Contains(signed.Body.String(), "Hidden work") { |
| 168 | t.Fatalf("the owner's profile is missing her own private work:\n%s", signed.Body) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestProfileOfAnUnknownNameIsNotFound(t *testing.T) { |
| 173 | t.Parallel() |
| 174 | s := newSite(t) |
| 175 | rw := s.serve(t, nil, "/-/u/nobody") |
| 176 | if rw.Code != http.StatusNotFound { |
| 177 | t.Fatalf("unknown name = %d, want 404", rw.Code) |
| 178 | } |
| 179 | // The 404 is the one every missing page uses, so it cannot be used to learn |
| 180 | // that a name is not an account rather than that a page is missing. |
| 181 | if !strings.Contains(rw.Body.String(), "That page does not exist") { |
| 182 | t.Fatalf("unknown name rendered the wrong page:\n%s", rw.Body) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestRetiredProfileStillOpens(t *testing.T) { |
| 187 | t.Parallel() |
| 188 | s := newSite(t) |
| 189 | if _, err := s.ident.Retire("bob", "alice"); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | rw := s.serve(t, nil, "/-/u/bob") |
| 193 | if rw.Code != http.StatusOK { |
| 194 | t.Fatalf("retired profile = %d", rw.Code) |
| 195 | } |
| 196 | if !strings.Contains(rw.Body.String(), "no longer active") { |
| 197 | t.Fatalf("a retired profile does not say so:\n%s", rw.Body) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestProfileFilterLinksAndHeading(t *testing.T) { |
| 202 | t.Parallel() |
| 203 | s := newSite(t) |
| 204 | body := s.serve(t, nil, "/-/u/alice").Body.String() |
| 205 | for _, want := range []string{ |
| 206 | `href="/-/u/alice?type=issues"`, |
| 207 | `href="/-/u/alice?type=changes"`, |
| 208 | `href="/-/u/alice?type=reviews"`, |
| 209 | `href="/-/u/alice?type=comments"`, |
| 210 | "Issues opened", |
| 211 | "Changes reviewed", |
| 212 | } { |
| 213 | if !strings.Contains(body, want) { |
| 214 | t.Fatalf("profile is missing %q:\n%s", want, body) |
| 215 | } |
| 216 | } |
| 217 | // The filtered page says which filter is on, so a bookmarked URL is not a |
| 218 | // mystery. |
| 219 | filtered := s.serve(t, nil, "/-/u/alice?type=reviews").Body.String() |
| 220 | if !strings.Contains(filtered, `class="chip active" href="/-/u/alice?type=reviews"`) { |
| 221 | t.Fatalf("the reviews filter is not marked active:\n%s", filtered) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestPeopleListsEveryAccountAndFilters(t *testing.T) { |
| 226 | t.Parallel() |
| 227 | s := newSite(t) |
| 228 | |
| 229 | all := s.serve(t, nil, "/-/u") |
| 230 | if all.Code != http.StatusOK { |
| 231 | t.Fatalf("directory = %d: %s", all.Code, all.Body) |
| 232 | } |
| 233 | body := all.Body.String() |
| 234 | for _, want := range []string{"alice", "adam", "bob", "Everyone with an account here", |
| 235 | "Counts only include repositories you can read."} { |
| 236 | if !strings.Contains(body, want) { |
| 237 | t.Fatalf("directory is missing %q:\n%s", want, body) |
| 238 | } |
| 239 | } |
| 240 | if strings.Contains(body, `<meta name="robots" content="noindex">`) == false { |
| 241 | t.Fatal("the directory does not ask crawlers to leave it alone") |
| 242 | } |
| 243 | |
| 244 | filtered := s.serve(t, nil, "/-/u?q=ali").Body.String() |
| 245 | if !strings.Contains(filtered, "alice") || strings.Contains(filtered, "adam") { |
| 246 | t.Fatalf("the name filter returned the wrong people:\n%s", filtered) |
| 247 | } |
| 248 | |
| 249 | empty := s.serve(t, nil, "/-/u?q=nobody").Body.String() |
| 250 | if !strings.Contains(empty, "No one matches") { |
| 251 | t.Fatalf("an empty search has no empty state:\n%s", empty) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // The directory is one table now, so a retired account is marked in its row and |
| 256 | // the status chips are how a reader narrows the list to one kind. |
| 257 | func TestRetiredAccountsAreMarkedAndFilterable(t *testing.T) { |
| 258 | t.Parallel() |
| 259 | s := newSite(t) |
| 260 | if _, err := s.ident.Retire("bob", "alice"); err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | |
| 264 | everyone := s.serve(t, nil, "/-/u").Body.String() |
| 265 | if !strings.Contains(everyone, `href="/-/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) { |
| 266 | t.Fatalf("the retired account is not marked in the table:\n%s", everyone) |
| 267 | } |
| 268 | |
| 269 | active := s.serve(t, nil, "/-/u?status=active").Body.String() |
| 270 | if strings.Contains(active, `href="/-/u/bob"`) { |
| 271 | t.Fatalf("the active filter kept a retired account:\n%s", active) |
| 272 | } |
| 273 | retired := s.serve(t, nil, "/-/u?status=retired").Body.String() |
| 274 | if !strings.Contains(retired, `href="/-/u/bob"`) || strings.Contains(retired, `href="/-/u/alice"`) { |
| 275 | t.Fatalf("the retired filter returned the wrong accounts:\n%s", retired) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // The header is the control: each column links to itself in the other direction, |
| 280 | // and the current one is marked for a screen reader. |
| 281 | func TestDirectorySortsByItsHeaders(t *testing.T) { |
| 282 | t.Parallel() |
| 283 | s := newSite(t) |
| 284 | |
| 285 | // Default is the name, A to Z. alice opened the fixture's issues; adam and bob |
| 286 | // have none, so a count order is a different question. |
| 287 | body := s.serve(t, nil, "/-/u").Body.String() |
| 288 | adam := strings.Index(body, `href="/-/u/adam"`) |
| 289 | alice := strings.Index(body, `href="/-/u/alice"`) |
| 290 | bob := strings.Index(body, `href="/-/u/bob"`) |
| 291 | if adam < 0 || alice < 0 || bob < 0 || !(adam < alice && alice < bob) { |
| 292 | t.Fatalf("the default order is not A to Z: adam=%d alice=%d bob=%d", adam, alice, bob) |
| 293 | } |
| 294 | if !strings.Contains(body, `aria-sort="ascending"`) { |
| 295 | t.Fatalf("the active column is not marked:\n%s", body) |
| 296 | } |
| 297 | // A count column starts with the largest first when it is first chosen. |
| 298 | if !strings.Contains(body, "sort=issues") || !strings.Contains(body, "dir=desc") { |
| 299 | t.Fatalf("the Issues header does not ask for the largest first:\n%s", body) |
| 300 | } |
| 301 | |
| 302 | ordered := s.serve(t, nil, "/-/u?sort=issues&dir=desc").Body.String() |
| 303 | if strings.Index(ordered, `href="/-/u/alice"`) > strings.Index(ordered, `href="/-/u/adam"`) { |
| 304 | t.Fatalf("the issues order does not put the only author first:\n%s", ordered) |
| 305 | } |
| 306 | if !strings.Contains(ordered, `aria-sort="descending"`) { |
| 307 | t.Fatalf("the issues order is not marked:\n%s", ordered) |
| 308 | } |
| 309 | // Clicking the same header again flips it. |
| 310 | if !strings.Contains(ordered, "dir=asc") { |
| 311 | t.Fatalf("the active header does not flip the direction:\n%s", ordered) |
| 312 | } |
| 313 | // A search keeps the order it was made in. |
| 314 | if !strings.Contains(ordered, `name="sort" value="issues"`) || |
| 315 | !strings.Contains(ordered, `name="dir" value="desc"`) { |
| 316 | t.Fatalf("the search form drops the order:\n%s", ordered) |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | func TestParsePeopleSort(t *testing.T) { |
| 321 | t.Parallel() |
| 322 | cases := []struct { |
| 323 | in, dir string |
| 324 | key string |
| 325 | desc bool |
| 326 | }{ |
| 327 | {"", "", sortName, false}, |
| 328 | {"name", "desc", sortName, true}, |
| 329 | {"issues", "desc", sortIssues, true}, |
| 330 | {"issues", "asc", sortIssues, false}, |
| 331 | // A count starts with the largest first when no direction is given. |
| 332 | {"reviews", "", sortReviews, true}, |
| 333 | {"joined", "", sortJoined, true}, |
| 334 | // An unknown key or direction is the default rather than an error. |
| 335 | {"bogus", "sideways", sortName, false}, |
| 336 | } |
| 337 | for _, c := range cases { |
| 338 | got := parsePeopleSort(c.in, c.dir) |
| 339 | if got.key != c.key || got.desc != c.desc { |
| 340 | t.Errorf("parsePeopleSort(%q, %q) = %+v", c.in, c.dir, got) |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestSortPeopleFallsBackToTheName(t *testing.T) { |
| 346 | t.Parallel() |
| 347 | rows := []peopleRow{ |
| 348 | {Name: "carol", Summary: index.ActorSummary{Issues: 2}}, |
| 349 | {Name: "alice", Summary: index.ActorSummary{Issues: 2}}, |
| 350 | {Name: "bob", Summary: index.ActorSummary{Issues: 1}}, |
| 351 | } |
| 352 | sortPeople(rows, peopleSort{key: sortIssues, desc: true}) |
| 353 | if rows[0].Name != "alice" || rows[1].Name != "carol" || rows[2].Name != "bob" { |
| 354 | t.Fatalf("sorted rows = %v", rows) |
| 355 | } |
| 356 | sortPeople(rows, peopleSort{key: sortName}) |
| 357 | if rows[0].Name != "alice" || rows[1].Name != "bob" || rows[2].Name != "carol" { |
| 358 | t.Fatalf("name order = %v", rows) |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | // TestProfileTotals counts the grouped rows the way the panel and the cards must |
| 363 | // agree on, and drops a release: its actor is a tag's tagger, not a credential. |
| 364 | func TestProfileTotals(t *testing.T) { |
| 365 | t.Parallel() |
| 366 | names := map[string]string{"r1": "demo", "r2": "other"} |
| 367 | counts := []index.ActorCount{ |
| 368 | {RepoID: "r1", Kind: "issue", Type: "open", N: 3}, |
| 369 | {RepoID: "r1", Kind: "issue", Type: "state", State: "closed", N: 4}, |
| 370 | {RepoID: "r1", Kind: "change", Type: "open", N: 2}, |
| 371 | {RepoID: "r1", Kind: "change", Type: "approval", N: 1}, |
| 372 | {RepoID: "r1", Kind: "issue", Type: "comment", N: 5}, |
| 373 | {RepoID: "r1", Kind: "change", Type: "comment", N: 2}, |
| 374 | {RepoID: "r2", Kind: "change", Type: "state", State: "merged", N: 1}, |
| 375 | {RepoID: "r2", Kind: "release", Type: "release", N: 9}, |
| 376 | } |
| 377 | stats, repos := profileTotals(counts, names) |
| 378 | if stats.Issues != 3 || stats.Changes != 2 || stats.Reviews != 1 || stats.Comments != 7 { |
| 379 | t.Fatalf("stats = %+v", stats) |
| 380 | } |
| 381 | if len(repos) != 1 || repos[0].Name != "demo" { |
| 382 | t.Fatalf("repos = %+v, want only demo (other's work is state changes and a release)", repos) |
| 383 | } |
| 384 | if repos[0].Issues != 3 || repos[0].Changes != 2 || repos[0].Reviews != 1 || repos[0].Comments != 7 { |
| 385 | t.Fatalf("demo card = %+v", repos[0]) |
| 386 | } |
| 387 | |
| 388 | // An empty answer is empty, not a zeroed card. |
| 389 | if stats, repos = profileTotals(nil, names); len(repos) != 0 || stats != (profileStats{}) { |
| 390 | t.Fatalf("no rows = %+v, %+v", stats, repos) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | func TestParseProfileFilter(t *testing.T) { |
| 395 | t.Parallel() |
| 396 | cases := []struct { |
| 397 | in string |
| 398 | kind string |
| 399 | typ string |
| 400 | headline string |
| 401 | query string |
| 402 | }{ |
| 403 | {"", "", "", "", ""}, |
| 404 | {"issues", "issue", "", "Issues", "issues"}, |
| 405 | {"changes", "change", "", "Changes", "changes"}, |
| 406 | {"reviews", "", "approval", "Reviews", "reviews"}, |
| 407 | {"comments", "", "comment", "Comments", "comments"}, |
| 408 | // An unknown filter is the whole feed rather than an error, so a stale |
| 409 | // link still shows something — and keeps nothing in its page links. |
| 410 | {"everything", "", "", "", ""}, |
| 411 | // The page's spelling is normalised, so `?type=Reviews` still marks the |
| 412 | // Reviews chip active and pages as `?type=reviews`. |
| 413 | {"Reviews", "", "approval", "Reviews", "reviews"}, |
| 414 | } |
| 415 | for _, c := range cases { |
| 416 | got := parseProfileFilter(c.in) |
| 417 | if got.kind != c.kind || got.typ != c.typ || got.label != c.headline || got.query != c.query { |
| 418 | t.Errorf("parseProfileFilter(%q) = %+v", c.in, got) |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) { |
| 424 | t.Parallel() |
| 425 | if got := profileURL("alice", "")(1); got != "/-/u/alice" { |
| 426 | t.Fatalf("first page = %q", got) |
| 427 | } |
| 428 | // A filtered page keeps the filter the reader chose, in the page's spelling. |
| 429 | if got := profileURL("alice", "reviews")(2); got != "/-/u/alice?page=2&type=reviews" { |
| 430 | t.Fatalf("filtered page = %q", got) |
| 431 | } |
| 432 | // The default directory URL stays short. |
| 433 | if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/-/u" { |
| 434 | t.Fatalf("directory first page = %q", got) |
| 435 | } |
| 436 | if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/-/u?page=2&q=ali" { |
| 437 | t.Fatalf("searched page = %q", got) |
| 438 | } |
| 439 | // The search, the status and the order all survive a page link. |
| 440 | if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/-/u?dir=desc&page=2&q=a&sort=reviews&status=active" { |
| 441 | t.Fatalf("filtered and sorted page = %q", got) |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // TestUserRefLinksOnlyAccounts: a name becomes a link only when it belongs to an |
| 446 | // account, so a commit author who never registered and a typo in a mention stay |
| 447 | // text instead of becoming a link that 404s. |
| 448 | func TestUserRefLinksOnlyAccounts(t *testing.T) { |
| 449 | t.Parallel() |
| 450 | s := newSite(t) |
| 451 | w := s.web |
| 452 | |
| 453 | known := w.userRef("alice") |
| 454 | if !known.Known || known.Href != "/-/u/alice" || known.Name != "alice" { |
| 455 | t.Fatalf("known account = %+v", known) |
| 456 | } |
| 457 | unknown := w.userRef("someone-who-never-registered") |
| 458 | if unknown.Known || unknown.Href != "" || unknown.Name != "someone-who-never-registered" { |
| 459 | t.Fatalf("unknown name = %+v", unknown) |
| 460 | } |
| 461 | if empty := w.userRef(""); empty.Known || empty.Name != "" { |
| 462 | t.Fatalf("empty name = %+v", empty) |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | // A directory of a large project pages server-side, and the page link keeps the |
| 467 | // question the reader asked: the order, the status and the search. |
| 468 | func TestDirectoryPaginates(t *testing.T) { |
| 469 | t.Parallel() |
| 470 | s := newSite(t) |
| 471 | for i := 0; i < peoplePage+5; i++ { |
| 472 | if _, _, err := s.ident.CreateWithInvite(fmt.Sprintf("user-%03d", i), "setup", time.Hour); err != nil { |
| 473 | t.Fatal(err) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | first := s.serve(t, nil, "/-/u").Body.String() |
| 478 | if got := strings.Count(first, `class="people-name"`); got != peoplePage { |
| 479 | t.Fatalf("first page shows %d rows, want %d", got, peoplePage) |
| 480 | } |
| 481 | if !strings.Contains(first, `href="/-/u?page=2"`) { |
| 482 | t.Fatalf("the first page has no link to the second:\n%s", first) |
| 483 | } |
| 484 | |
| 485 | second := s.serve(t, nil, "/-/u?sort=joined&dir=desc&page=2").Body.String() |
| 486 | want := 3 + peoplePage + 5 - peoplePage // the fixture's accounts plus the extra ones |
| 487 | if got := strings.Count(second, `class="people-name"`); got != want { |
| 488 | t.Fatalf("second page shows %d rows, want %d", got, want) |
| 489 | } |
| 490 | if !strings.Contains(second, "sort=joined") || !strings.Contains(second, "dir=desc") { |
| 491 | t.Fatalf("a page link drops the order:\n%s", second) |
| 492 | } |
| 493 | } |
| 494 |