v0.36.0 1fb2421

mr_async_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"net/http/httptest"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20	"bnema.dev/ermite/internal/ports/mocks"
21)
22
23func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24	t.Helper()
25	svc := mocks.NewMergeRequestService(t)
26	browse := mocks.NewBrowseService(t)
27	browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28	svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29		MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30		Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31	}, nil).Maybe()
32	return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33}
34
35func TestAsyncMergePost(t *testing.T) {
36	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37		for _, hx := range []bool{false, true} {
38			t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39				s, svc := asyncMergeServer(t, domain.MRStateMerged)
40				svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41				r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42				if hx {
43					r.Header.Set("HX-Request", "true")
44				}
45				w := httptest.NewRecorder()
46				s.mrAction(s.mrMerge)(w, r)
47				if !hx {
48					assert.Equal(t, http.StatusSeeOther, w.Code)
49					return
50				}
51				assert.Equal(t, http.StatusOK, w.Code)
52				assert.Empty(t, w.Header().Get("Location"))
53				assert.Contains(t, w.Body.String(), `id="mr-state"`)
54				assert.Contains(t, w.Body.String(), `data-state="merged"`)
55				assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56				assert.NotContains(t, w.Body.String(), "<html")
57				assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58			})
59		}
60	}
61}
62
63func TestAsyncMergeRefusal(t *testing.T) {
64	s, svc := asyncMergeServer(t, domain.MRStateOpen)
65	svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66	r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67	r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68	r.Header.Set("HX-Request", "true")
69	w := httptest.NewRecorder()
70	s.mrAction(s.mrMerge)(w, r)
71	assert.Equal(t, http.StatusConflict, w.Code)
72	assert.Contains(t, w.Body.String(), `role="alert"`)
73	assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74	assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75	assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76	assert.Contains(t, w.Body.String(), "Merging…")
77	assert.NotContains(t, w.Body.String(), "<html")
78}
79
80func TestAsyncMergeLive(t *testing.T) {
81	s, _ := asyncMergeServer(t, domain.MRStateMerged)
82	l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83	fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84	require.NoError(t, err)
85	require.Len(t, fs, 1)
86	assert.Equal(t, "mr-mergeability", fs[0].event)
87	assert.Contains(t, fs[0].html, `data-state="merged"`)
88	assert.Contains(t, fs[0].html, `id="mr-actions"`)
89	assert.False(t, strings.Contains(fs[0].html, "reload"))
90	fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91	require.NoError(t, err)
92	assert.Empty(t, fs)
93}
94
95func TestAsyncMergeOtherFailures(t *testing.T) {
96	for _, tt := range []struct {
97		name      string
98		mergeErr  error
99		readFails bool
100		status    int
101		message   string
102	}{
103		{"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104		{"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105		{"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106	} {
107		t.Run(tt.name, func(t *testing.T) {
108			s, svc := asyncMergeServer(t, domain.MRStateMerged)
109			if tt.readFails {
110				svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111				svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112			}
113			svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114			r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115			r.Header.Set("HX-Request", "true")
116			w := httptest.NewRecorder()
117			s.mrAction(s.mrMerge)(w, r)
118			assert.Equal(t, tt.status, w.Code)
119			assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120			assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121			assert.Contains(t, w.Body.String(), `role="alert"`)
122			assert.Contains(t, w.Body.String(), tt.message)
123			assert.NotContains(t, w.Body.String(), "private")
124			assert.NotContains(t, w.Body.String(), "<html")
125		})
126	}
127}
128
129func TestMergeGateRefusalFragment(t *testing.T) {
130	s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131	for _, origin := range []string{testPublicURL, "https://other.example"} {
132		for _, hx := range []bool{false, true} {
133			r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134			r.Header.Set("Origin", origin)
135			r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136			if hx {
137				r.Header.Set("HX-Request", "true")
138			}
139			w := httptest.NewRecorder()
140			s.ServeHTTP(w, r)
141			assert.Equal(t, http.StatusForbidden, w.Code)
142			if hx {
143				assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144				assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145				if origin == testPublicURL {
146					assert.Contains(t, w.Body.String(), "your session has expired")
147				} else {
148					assert.Contains(t, w.Body.String(), "origin was not accepted")
149				}
150			} else {
151				assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152			}
153		}
154	}
155}
156
157func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158	s, svc := asyncMergeServer(t, domain.MRStateMerged)
159	svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160	svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161	r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162	w := httptest.NewRecorder()
163	s.mrAction(s.mrMerge)(w, r)
164	assert.Equal(t, http.StatusConflict, w.Code)
165	assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166}

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