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