detached fda3c2b

web_inbox_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"context"
8	"net/http"
9	"strings"
10	"testing"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19// The owner's inbox end to end: two repositories with open merge requests and
20// issues, the filter, the home button, the owner stream, and a 404 for
21// everyone who is not the signed-in owner.
22func TestWebInboxEndToEnd(t *testing.T) {
23	r := newMRRepo(t, domain.VisibilityPublic)
24	b := browser{r.harness}
25	token := r.signIn(t)
26
27	_, err := r.forge.Repos.Create(r.ctx, domain.Root(), createParams(r.harness, "other", domain.VisibilityPrivate))
28	require.NoError(t, err)
29	otherURL := r.urlAuth(adminToken, "/other.git")
30	r.mustGit(r.wt, "push", "-q", otherURL, "main")
31	r.mustGit(r.wt, "push", "-q", otherURL, "feat/search")
32
33	owner := domain.IssuesOwner
34	for _, n := range []string{"proj", "other"} {
35		_, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(n), ports.UpdateRepoParams{Issues: &owner})
36		require.NoError(t, err)
37	}
38
39	// Nothing is open yet.
40	empty := b.get("/-/inbox", as(token))
41	require.Equal(t, 200, empty.status, empty.body)
42	assert.Contains(t, empty.body, "no open merge requests.")
43	assert.Contains(t, empty.body, "no open issues.")
44
45	// The owner stream sees both repositories.
46	stream := ownerStream(t, r.harness, token)
47
48	root := domain.Root()
49	_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50	require.NoError(t, err)
51	ev := nextFragment(t, stream)
52	assert.Equal(t, "inbox", ev.name)
53	assert.Contains(t, ev.data, `id="inbox-counts"`)
54	for range 4 { // the other fragments of this event
55		nextFragment(t, stream)
56	}
57	_, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58	require.NoError(t, err)
59	var seen strings.Builder
60	for range 5 { // the five fragments of this event
61		seen.WriteString(nextFragment(t, stream).data)
62	}
63	assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
64	_, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
65	require.NoError(t, err)
66	_, err = r.forge.Issues.Create(r.ctx, root, r.name("proj"), ports.CreateIssueParams{Title: "proj bug"})
67	require.NoError(t, err)
68
69	all := b.get("/-/inbox", as(token))
70	require.Equal(t, 200, all.status, all.body)
71	assert.Equal(t, "private, no-store", all.hdr.Get("Cache-Control"))
72	assert.Contains(t, all.body, "<strong>2</strong> merge requests · <strong>2</strong> issues")
73	for _, want := range []string{"add search", "other work", "search is slow", "proj bug", `href="/other/issues/`, `href="/proj/merge-requests/`} {
74		assert.Contains(t, all.body, want)
75	}
76
77	only := b.get("/-/inbox?repo=other", as(token))
78	require.Equal(t, 200, only.status)
79	assert.Contains(t, only.body, "<strong>2</strong> merge requests · <strong>2</strong> issues", "the counters ignore the filter")
80	assert.Contains(t, only.body, "other work")
81	assert.Contains(t, only.body, "search is slow")
82	assert.NotContains(t, only.body, "add search")
83	assert.NotContains(t, only.body, "proj bug")
84
85	// Issues off: that repository's issues leave the inbox, its merge requests stay.
86	off := domain.IssuesOff
87	_, err = r.forge.Repos.Update(r.ctx, root, r.name("other"), ports.UpdateRepoParams{Issues: &off})
88	require.NoError(t, err)
89	hidden := b.get("/-/inbox", as(token))
90	assert.Contains(t, hidden.body, "<strong>2</strong> merge requests · <strong>1</strong> issues")
91	assert.NotContains(t, hidden.body, "search is slow")
92
93	// The home button, for the owner only.
94	assert.Contains(t, b.get("/", as(token)).body, `href="/-/inbox"`)
95	anonHome := b.get("/", nil)
96	assert.NotContains(t, anonHome.body, "/-/inbox")
97	assert.NotContains(t, anonHome.body, "Inbox")
98
99	// Anyone else: the 404 of an unknown page, and no stream.
100	missing := b.get("/-/no-such-page", nil)
101	anon := b.get("/-/inbox", nil)
102	assert.Equal(t, 404, anon.status)
103	assert.Equal(t, missing.body, anon.body)
104	assert.Equal(t, 404, b.get("/-/inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status, "a token is not a session")
105	unknown := b.get("/-/events?topic=nope&view=inbox", nil)
106	ownerAnon := b.get("/-/events?topic=owner&view=inbox", nil)
107	assert.Equal(t, unknown.status, ownerAnon.status, "the answer to an unknown topic")
108	assert.Equal(t, strings.ReplaceAll(unknown.body, "nope", "owner"), ownerAnon.body)
109	assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
110}
111
112// nextFragment returns the next event that carries a fragment. Events the
113// inbox does not show (a mergeability check) still send their id alone, so a
114// resumed stream starts after them; they are skipped.
115func nextFragment(t *testing.T, s *sseStream) sseEvent {
116	t.Helper()
117	for {
118		if ev := s.next(t, watchWait); ev.name != "" {
119			return ev
120		}
121	}
122}
123
124// ownerStream opens the owner topic with the session cookie.
125func ownerStream(t *testing.T, h *harness, token string) *sseStream {
126	t.Helper()
127	ctx, cancel := context.WithCancel(h.ctx)
128	t.Cleanup(cancel)
129	req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
130	require.NoError(t, err)
131	req.Header.Set("Cookie", sessionCookie+"="+token)
132	resp, err := http.DefaultClient.Do(req)
133	require.NoError(t, err)
134	t.Cleanup(func() { _ = resp.Body.Close() })
135	require.Equal(t, http.StatusOK, resp.StatusCode)
136	s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
137	go func() {
138		defer close(s.events)
139		sc := bufio.NewScanner(resp.Body)
140		sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
141		var cur sseEvent
142		var data []string
143		for sc.Scan() {
144			line := sc.Text()
145			switch {
146			case line == "":
147				if cur.id != "" || cur.name != "" || len(data) > 0 {
148					cur.data = strings.Join(data, "\n")
149					s.events <- cur
150				}
151				cur, data = sseEvent{}, nil
152			case strings.HasPrefix(line, ":"):
153				cur.comment = true
154			case strings.HasPrefix(line, "event: "):
155				cur.name = strings.TrimPrefix(line, "event: ")
156			case strings.HasPrefix(line, "id: "):
157				cur.id = strings.TrimPrefix(line, "id: ")
158			case strings.HasPrefix(line, "data: "):
159				data = append(data, strings.TrimPrefix(line, "data: "))
160			}
161		}
162	}()
163	return s
164}

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 2

Tags 45

↑↓ moveenter openesc close