detached 388d3be

web_issue_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"net/url"
7	"regexp"
8	"strconv"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19
20// The issue pages end to end: the owner opens, comments on, closes and
21// reopens an issue through the forms; an anonymous visitor reads them on a
22// public repository and cannot write; with issues off every page is a 404.
23func TestWebIssuePagesEndToEnd(t *testing.T) {
24	r := newMRRepo(t, domain.VisibilityPublic)
25	b := browser{r.harness}
26	token := r.signIn(t)
27
28	// Off by default: no page, no tab, for the owner as well.
29	assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30	assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31	assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
32
33	mode := domain.IssuesOwner
34	_, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35	require.NoError(t, err)
36
37	home := b.get("/proj", nil)
38	require.Equal(t, 200, home.status)
39	assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40
41	empty := b.get("/proj/issues", nil)
42	require.Equal(t, 200, empty.status, empty.body)
43	assert.Contains(t, empty.body, "no open issues")
44	assert.NotContains(t, empty.body, "New issue")
45
46	// The form is the owner's.
47	assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48	form := b.get("/proj/issues/new", as(token))
49	require.Equal(t, 200, form.status)
50	assert.Contains(t, form.body, `action="/proj/issues"`)
51
52	// A POST without the session is refused.
53	anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54	assert.Equal(t, 403, anon.status)
55
56	// Create.
57	created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58	require.Equal(t, 303, created.status, created.body)
59	assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60	m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61	require.Len(t, m, 2, created.hdr.Get("Location"))
62	path := "/proj/issues/" + m[1]
63
64	owner := b.get(path, as(token))
65	require.Equal(t, 200, owner.status, owner.body)
66	assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67	assert.Contains(t, owner.body, "search is slow")
68	assert.Contains(t, owner.body, "<strong>find</strong>")
69	assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70	assert.Contains(t, owner.body, `action="`+path+`/close"`)
71
72	// An invalid title is a state page, nothing is created.
73	bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74	assert.Equal(t, 400, bad.status)
75
76	// Comment.
77	com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78	require.Equal(t, 303, com.status, com.body)
79	assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80	assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {"  "}}).status, "a blank comment")
81
82	// Anonymous reads a public repository: no forms, public cache headers.
83	pub := b.get(path, nil)
84	require.Equal(t, 200, pub.status)
85	assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86	assert.Contains(t, pub.body, "reproduced")
87	assert.NotContains(t, pub.body, `class="cform"`)
88	assert.NotContains(t, pub.body, path+"/close")
89	list := b.get("/proj/issues", nil)
90	assert.Contains(t, list.body, `href="`+path+`"`)
91	assert.Contains(t, list.body, "search is slow")
92	assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93
94	// Close, then reopen.
95	closed := b.form(path+"/close", token, nil)
96	require.Equal(t, 303, closed.status, closed.body)
97	assert.Equal(t, path, closed.hdr.Get("Location"))
98	assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99	assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100	again := b.get(path, as(token))
101	assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102	assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103
104	reopened := b.form(path+"/reopen", token, nil)
105	require.Equal(t, 303, reopened.status, reopened.body)
106	assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107
108	// The number is shared with the merge requests: #n goes to the right page.
109	mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110		Title: "add search", Source: "feat/search", Target: "main",
111	})
112	require.NoError(t, err)
113	to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114	assert.Equal(t, 303, to.status)
115	assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116	back := b.get("/proj/merge-requests/"+m[1], nil)
117	assert.Equal(t, 303, back.status)
118	assert.Equal(t, path, back.hdr.Get("Location"))
119	assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120	assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121
122	// Private repository: invisible to an anonymous visitor.
123	priv := domain.VisibilityPrivate
124	_, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125	require.NoError(t, err)
126	assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127	assert.Equal(t, 404, b.get(path, nil).status)
128	assert.Equal(t, 200, b.get(path, as(token)).status)
129
130	// Off again: 404 for everyone.
131	off := domain.IssuesOff
132	_, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133	require.NoError(t, err)
134	assert.Equal(t, 404, b.get(path, as(token)).status)
135	assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136	assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137}
138
139func itoa64(n int64) string { return strconv.FormatInt(n, 10) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close