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 := stream.next(t, watchWait)
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 stream.next(t, watchWait)
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(stream.next(t, watchWait).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// ownerStream opens the owner topic with the session cookie.
113func ownerStream(t *testing.T, h *harness, token string) *sseStream {
114 t.Helper()
115 ctx, cancel := context.WithCancel(h.ctx)
116 t.Cleanup(cancel)
117 req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?topic=owner&view=inbox"), nil)
118 require.NoError(t, err)
119 req.Header.Set("Cookie", sessionCookie+"="+token)
120 resp, err := http.DefaultClient.Do(req)
121 require.NoError(t, err)
122 t.Cleanup(func() { _ = resp.Body.Close() })
123 require.Equal(t, http.StatusOK, resp.StatusCode)
124 s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
125 go func() {
126 defer close(s.events)
127 sc := bufio.NewScanner(resp.Body)
128 sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
129 var cur sseEvent
130 var data []string
131 for sc.Scan() {
132 line := sc.Text()
133 switch {
134 case line == "":
135 if cur.id != "" || cur.name != "" || len(data) > 0 {
136 cur.data = strings.Join(data, "\n")
137 s.events <- cur
138 }
139 cur, data = sseEvent{}, nil
140 case strings.HasPrefix(line, ":"):
141 cur.comment = true
142 case strings.HasPrefix(line, "event: "):
143 cur.name = strings.TrimPrefix(line, "event: ")
144 case strings.HasPrefix(line, "id: "):
145 cur.id = strings.TrimPrefix(line, "id: ")
146 case strings.HasPrefix(line, "data: "):
147 data = append(data, strings.TrimPrefix(line, "data: "))
148 }
149 }
150 }()
151 return s
152}