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.
|
a6b42433
Keenan Nemetz
7d ago
|
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" | |
|
4739664b
Keenan Nemetz
7d ago
|
10 | "fmt" |
|
a6b42433
Keenan Nemetz
7d ago
|
11 | "net/http" |
| 12 | "net/http/httptest" | |
| 13 | "path/filepath" | |
| 14 | "strings" | |
| 15 | "testing" | |
| 16 | "time" | |
| 17 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
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" | |
|
a6b42433
Keenan Nemetz
7d ago
|
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() | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
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) | |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
143 | anon := s.serve(t, nil, "/-/u/alice") |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
163 | signed := s.serve(t, s.account(t, "alice"), "/-/u/alice") |
|
a6b42433
Keenan Nemetz
7d ago
|
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) | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
175 | rw := s.serve(t, nil, "/-/u/nobody") |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
192 | rw := s.serve(t, nil, "/-/u/bob") |
|
a6b42433
Keenan Nemetz
7d ago
|
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) | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
204 | body := s.serve(t, nil, "/-/u/alice").Body.String() |
|
a6b42433
Keenan Nemetz
7d ago
|
205 | for _, want := range []string{ |
|
ed03b3d1
Keenan Nemetz
3h ago
|
206 | `href="/-/u/alice?type=issues"`, |
| 207 | `href="/-/u/alice?type=changes"`, | |
| 208 | `href="/-/u/alice?type=reviews"`, | |
| 209 | `href="/-/u/alice?type=comments"`, | |
|
a6b42433
Keenan Nemetz
7d ago
|
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. | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
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"`) { | |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
229 | all := s.serve(t, nil, "/-/u") |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
244 | filtered := s.serve(t, nil, "/-/u?q=ali").Body.String() |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
249 | empty := s.serve(t, nil, "/-/u?q=nobody").Body.String() |
|
a6b42433
Keenan Nemetz
7d ago
|
250 | if !strings.Contains(empty, "No one matches") { |
| 251 | t.Fatalf("an empty search has no empty state:\n%s", empty) | |
| 252 | } | |
| 253 | } | |
| 254 | ||
|
4739664b
Keenan Nemetz
7d ago
|
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) { | |
|
a6b42433
Keenan Nemetz
7d ago
|
258 | t.Parallel() |
| 259 | s := newSite(t) | |
| 260 | if _, err := s.ident.Retire("bob", "alice"); err != nil { | |
| 261 | t.Fatal(err) | |
| 262 | } | |
|
4739664b
Keenan Nemetz
7d ago
|
263 | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
264 | everyone := s.serve(t, nil, "/-/u").Body.String() |
| 265 | if !strings.Contains(everyone, `href="/-/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) { | |
|
4739664b
Keenan Nemetz
7d ago
|
266 | t.Fatalf("the retired account is not marked in the table:\n%s", everyone) |
| 267 | } | |
| 268 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
269 | active := s.serve(t, nil, "/-/u?status=active").Body.String() |
| 270 | if strings.Contains(active, `href="/-/u/bob"`) { | |
|
4739664b
Keenan Nemetz
7d ago
|
271 | t.Fatalf("the active filter kept a retired account:\n%s", active) |
| 272 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
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"`) { | |
|
4739664b
Keenan Nemetz
7d ago
|
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. | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
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"`) | |
|
4739664b
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
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"`) { | |
|
4739664b
Keenan Nemetz
7d ago
|
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}, | |
|
a6b42433
Keenan Nemetz
7d ago
|
336 | } |
|
4739664b
Keenan Nemetz
7d ago
|
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 | } | |
|
a6b42433
Keenan Nemetz
7d ago
|
342 | } |
|
4739664b
Keenan Nemetz
7d ago
|
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) | |
|
a6b42433
Keenan Nemetz
7d ago
|
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() | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
425 | if got := profileURL("alice", "")(1); got != "/-/u/alice" { |
|
a6b42433
Keenan Nemetz
7d ago
|
426 | t.Fatalf("first page = %q", got) |
| 427 | } | |
| 428 | // A filtered page keeps the filter the reader chose, in the page's spelling. | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
429 | if got := profileURL("alice", "reviews")(2); got != "/-/u/alice?page=2&type=reviews" { |
|
a6b42433
Keenan Nemetz
7d ago
|
430 | t.Fatalf("filtered page = %q", got) |
| 431 | } | |
|
4739664b
Keenan Nemetz
7d ago
|
432 | // The default directory URL stays short. |
|
ed03b3d1
Keenan Nemetz
3h ago
|
433 | if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/-/u" { |
|
a6b42433
Keenan Nemetz
7d ago
|
434 | t.Fatalf("directory first page = %q", got) |
| 435 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
436 | if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/-/u?page=2&q=ali" { |
|
a6b42433
Keenan Nemetz
7d ago
|
437 | t.Fatalf("searched page = %q", got) |
| 438 | } | |
|
4739664b
Keenan Nemetz
7d ago
|
439 | // The search, the status and the order all survive a page link. |
|
ed03b3d1
Keenan Nemetz
3h ago
|
440 | if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/-/u?dir=desc&page=2&q=a&sort=reviews&status=active" { |
|
4739664b
Keenan Nemetz
7d ago
|
441 | t.Fatalf("filtered and sorted page = %q", got) |
| 442 | } | |
|
a6b42433
Keenan Nemetz
7d ago
|
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") | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
454 | if !known.Known || known.Href != "/-/u/alice" || known.Name != "alice" { |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | } | |
|
4739664b
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
477 | first := s.serve(t, nil, "/-/u").Body.String() |
|
4739664b
Keenan Nemetz
7d ago
|
478 | if got := strings.Count(first, `class="people-name"`); got != peoplePage { |
| 479 | t.Fatalf("first page shows %d rows, want %d", got, peoplePage) | |
| 480 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
481 | if !strings.Contains(first, `href="/-/u?page=2"`) { |
|
4739664b
Keenan Nemetz
7d ago
|
482 | t.Fatalf("the first page has no link to the second:\n%s", first) |
| 483 | } | |
| 484 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
485 | second := s.serve(t, nil, "/-/u?sort=joined&dir=desc&page=2").Body.String() |
|
4739664b
Keenan Nemetz
7d ago
|
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 | } |